PSCMSnowflakePatching.psm1

#region Enums
enum EvaluationState {
    None
    Available
    Submitted
    Detecting
    PreDownload  
    Downloading  
    WaitInstall  
    Installing   
    PendingSoftReboot
    PendingHardReboot
    WaitReboot   
    Verifying
    InstallComplete  
    Error
    WaitServiceWindow
    WaitUserLogon
    WaitUserLogoff   
    WaitJobUserLogon 
    WaitUserReconnect
    PendingUserLogoff
    PendingUpdate
    WaitingRetry 
    WaitPresModeOff  
    WaitForOrchestration
}

enum TriggerSchedule {
    HardwareInventory = 1
    SoftwareInventory
    DataDiscoveryRecord
    FileCollection = 10
    IDMIFCollection
    ClientMachineAuthentication
    MachinePolicyAssignmentsRequest    = 21
    MachinePolicyEvaluation
    RefreshDefaultMPTask
    LocationServicesRefreshLocationsTask
    LocationServicesTimeoutRefreshTask
    UserPolicyAgentRequestAssignment
    UserPolicyAgentEvaluateAssignment
    SoftwareMeteringGeneratingUsageReport = 31
    SourceUpdateMessage
    ClearingProxySettingsCache = 37
    MachinePolicyAgentCleanup = 40
    UserPolicyAgentCleanup
    PolicyAgentValidateMachinePolicyAssignment
    PolicyAgentValidateUserPolicyAssignment
    RetryingOrRefreshingCertificatesInADonMP = 51
    PeerDPStatusReporting = 61
    PeerDPPendingPackageCheckSchedule
    SUMUpdatesInstallSchedule
    HardwareInventoryCollectionCycle = 101
    SoftwareInventoryCollectionCycle
    DiscoveryDataCollectionCycle
    FileCollectionCycle
    IDMIFCollectionCycle
    SoftwareMeteringUsageReportCycle
    WindowsInstallerSourceListUpdateCycle
    SoftwareUpdatesAssignmentsEvaluationCycle
    BranchDistributionPointMaintenanceTask
    SendUnsentStateMessage = 111
    StateSystemPolicyCacheCleanout
    ScanByUpdateSource
    UpdateStorePolicy
    StateSystemPolicyBulkSendHigh
    StateSystemPolicyBulkSendLow
    ApplicationManagerPolicyAction = 121
    ApplicationManagerUserPolicyAction
    ApplicationManagerGlobalEvaluationAction
    PowerManagementStartSummarizer = 131
    EndpointDeploymentReevaluate = 221
    EndpointAMPolicyReevaluate
    ExternalEventDetection
}
#endregion

#region Private
function GetBootTime {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$ComputerName,
        [Parameter()]
        [PSCredential]$Credential,
        [Parameter()]
        [Switch]$DCOMAuthentication
    )

    try {
        $GetCimInstanceSplat = @{
            Query = "Select LastBootUpTime from Win32_OperatingSystem"
            ErrorAction = "Stop"
        }
    
        if ($PSBoundParameters.ContainsKey("ComputerName")) {
            $NewCimSessionSplat = @{
                ComputerName = $ComputerName
                ErrorAction  = "Stop"
            }
        }
    
        if ($PSBoundParameters.ContainsKey("Credential")) {
            if ($DCOMAuthentication.IsPresent) {
                $Options                             = New-CimSessionOption -Protocol Dcom
                $NewCimSessionSplat["SessionOption"] = $Options
            }
            
            $NewCimSessionSplat["Credential"]  = $Credential
            $Session                           = New-CimSession @NewCimSessionSplat
            $GetCimInstanceSplat["CimSession"] = $Session
        }
    
        if (-not $PSBoundParameters.ContainsKey("Credential") -And $PSBoundParameters.ContainsKey("ComputerName")) {
            $GetCimInstanceSplat["ComputerName"] = $ComputerName
        }
    
        Get-CimInstance @getCimInstanceSplat | Select-Object -ExpandProperty LastBootUpTime
    
        if ($Session) { Remove-CimSession $Session -ErrorAction 'SilentlyContinue' }
    }
    catch {
        Write-Error $_ -ErrorAction $ErrorActionPreference
    }
}

function GetOS {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory)]
        [String[]]$ComputerName,
        [Parameter()]
        [PSCredential]$Credential,
        [Parameter()]
        [Switch]$DCOMAuthentication
    )

    try {
        $GetCimInstanceSplat = @{
            Query = "Select Caption from Win32_OperatingSystem"
            ErrorAction = "Stop"
        }
    
        if ($PSBoundParameters.ContainsKey("ComputerName")) {
            $NewCimSessionSplat = @{
                ComputerName = $ComputerName
                ErrorAction  = "Stop"
            }
        }
    
        if ($PSBoundParameters.ContainsKey("Credential")) {
            if ($DCOMAuthentication.IsPresent) {
                $Options                             = New-CimSessionOption -Protocol Dcom
                $NewCimSessionSplat["SessionOption"] = $Options
            }
            
            $NewCimSessionSplat["Credential"]  = $Credential
            $Session                           = New-CimSession @NewCimSessionSplat
            $GetCimInstanceSplat["CimSession"] = $Session
        }
    
        if (-not $PSBoundParameters.ContainsKey("Credential") -And $PSBoundParameters.ContainsKey("ComputerName")) {
            $GetCimInstanceSplat["ComputerName"] = $ComputerName
        }
    
        Get-CimInstance @getCimInstanceSplat | Select-Object -ExpandProperty Caption
    
        if ($Session) { Remove-CimSession $Session -ErrorAction 'SilentlyContinue' }
    }
    catch {
        Write-Error $_ -ErrorAction $ErrorActionPreference
    }
}

function NewLoopAction {
    <#
    .SYNOPSIS
        Function to loop a specified scriptblock until certain conditions are met
    .DESCRIPTION
        This function is a wrapper for a ForLoop or a DoUntil loop. This allows you to specify if you want to exit based on a timeout, or a number of iterations.
        Additionally, you can specify an optional delay between loops, and the type of dealy (Minutes, Seconds). If needed, you can also perform an action based on
        whether the 'Exit Condition' was met or not. This is the IfTimeoutScript and IfSucceedScript.
    .PARAMETER LoopTimeout
        A time interval integer which the loop should timeout after. This is for a DoUntil loop.
    .PARAMETER LoopTimeoutType
        Provides the time increment type for the LoopTimeout, defaulting to Seconds. ('Seconds', 'Minutes', 'Hours', 'Days')
    .PARAMETER LoopDelay
        An optional delay that will occur between each loop.
    .PARAMETER LoopDelayType
        Provides the time increment type for the LoopDelay between loops, defaulting to Seconds. ('Milliseconds', 'Seconds', 'Minutes')
    .PARAMETER Iterations
        Implies that a ForLoop is wanted. This will provide the maximum number of Iterations for the loop. [i.e. "for ($i = 0; $i -lt $Iterations; $i++)..."]
    .PARAMETER ScriptBlock
        A script block that will run inside the loop. Recommend encapsulating inside { } or providing a [scriptblock]
    .PARAMETER ExitCondition
        A script block that will act as the exit condition for the do-until loop. Will be evaluated each loop. Recommend encapsulating inside { } or providing a [scriptblock]
    .PARAMETER IfTimeoutScript
        A script block that will act as the script to run if the timeout occurs. Recommend encapsulating inside { } or providing a [scriptblock]
    .PARAMETER IfSucceedScript
        A script block that will act as the script to run if the exit condition is met. Recommend encapsulating inside { } or providing a [scriptblock]
    .EXAMPLE
        C:\PS> $newLoopActionSplat = @{
                    LoopTimeoutType = 'Seconds'
                    ScriptBlock = { 'Bacon' }
                    ExitCondition = { 'Bacon' -Eq 'eggs' }
                    IfTimeoutScript = { 'Breakfast'}
                    LoopDelayType = 'Seconds'
                    LoopDelay = 1
                    LoopTimeout = 10
                }
                New-LoopAction @newLoopActionSplat
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Bacon
                Breakfast
    .EXAMPLE
        C:\PS> $newLoopActionSplat = @{
                    ScriptBlock = { if($Test -eq $null){$Test = 0};$TEST++ }
                    ExitCondition = { $Test -eq 4 }
                    IfTimeoutScript = { 'Breakfast' }
                    IfSucceedScript = { 'Dinner'}
                    Iterations = 5
                    LoopDelay = 1
                }
                New-LoopAction @newLoopActionSplat
                Dinner
        C:\PS> $newLoopActionSplat = @{
                    ScriptBlock = { if($Test -eq $null){$Test = 0};$TEST++ }
                    ExitCondition = { $Test -eq 6 }
                    IfTimeoutScript = { 'Breakfast' }
                    IfSucceedScript = { 'Dinner'}
                    Iterations = 5
                    LoopDelay = 1
                }
                New-LoopAction @newLoopActionSplat
                Breakfast
    .NOTES
        Play with the conditions a bit. I've tried to provide some examples that demonstrate how the loops, timeouts, and scripts work!

        Author: Cody Mathis (@CodyMathis123)
        https://github.com/CodyMathis123/CM-Ramblings/blob/master/New-LoopAction.ps1
    #>

    param
    (
        [Parameter()]
        [String]$Name = 'NoName',
        [parameter(Mandatory = $true, ParameterSetName = 'DoUntil')]
        [int32]$LoopTimeout,
        [parameter(Mandatory = $true, ParameterSetName = 'DoUntil')]
        [ValidateSet('Seconds', 'Minutes', 'Hours', 'Days')]
        [string]$LoopTimeoutType,
        [parameter(Mandatory = $true)]
        [int32]$LoopDelay,
        [parameter(Mandatory = $false)]
        [ValidateSet('Milliseconds', 'Seconds', 'Minutes')]
        [string]$LoopDelayType = 'Seconds',
        [parameter(Mandatory = $true, ParameterSetName = 'ForLoop')]
        [int32]$Iterations,
        [parameter(Mandatory = $true)]
        [scriptblock]$ScriptBlock,
        [parameter(Mandatory = $true, ParameterSetName = 'DoUntil')]
        [parameter(Mandatory = $false, ParameterSetName = 'ForLoop')]
        [scriptblock]$ExitCondition,
        [parameter(Mandatory = $false)]
        [scriptblock]$IfTimeoutScript,
        [parameter(Mandatory = $false)]
        [scriptblock]$IfSucceedScript
    )
    begin {
        Write-Verbose ('New-LoopAction: [{0}] Started' -f $Name)
        switch ($PSCmdlet.ParameterSetName) {
            'DoUntil' {
                $paramNewTimeSpan = @{
                    $LoopTimeoutType = $LoopTimeout
                }    
                $TimeSpan = New-TimeSpan @paramNewTimeSpan
                $StopWatch = [System.Diagnostics.Stopwatch]::StartNew()
                $FirstRunDone = $false        
            }
            'ForLoop' {
                $FirstRunDone = $false
            }
        }
    }
    process {
        switch ($PSCmdlet.ParameterSetName) {
            'DoUntil' {
                do {
                    switch ($FirstRunDone) {
                        $false {
                            $FirstRunDone = $true
                        }
                        Default {
                            $paramStartSleep = @{
                                $LoopDelayType = $LoopDelay
                            }
                            Start-Sleep @paramStartSleep
                        }
                    }
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Executing script block' -f $Name)
                    . $ScriptBlock
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Done, executing exit condition script block' -f $Name)
                    $ExitConditionResult = . $ExitCondition
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Done, exit condition result is {1} and elapsed time is {2}' -f $Name, $ExitConditionResult, $StopWatch.Elapsed)
                }
                until ($ExitConditionResult -eq $true -or $StopWatch.Elapsed -ge $TimeSpan)
            }
            'ForLoop' {
                for ($i = 0; $i -lt $Iterations; $i++) {
                    switch ($FirstRunDone) {
                        $false {
                            $FirstRunDone = $true
                        }
                        Default {
                            $paramStartSleep = @{
                                $LoopDelayType = $LoopDelay
                            }
                            Start-Sleep @paramStartSleep
                        }
                    }
                    Write-Verbose ('New-LoopAction: [{0}] [ForLoop - {1}/{2}] Executing script block' -f $Name, $i, $Iterations)
                    . $ScriptBlock
                    if ($PSBoundParameters.ContainsKey('ExitCondition')) {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Done, executing exit condition script block' -f $Name)
                        if (. $ExitCondition) {
                            $ExitConditionResult = $true
                            break
                        }
                        else {
                            $ExitConditionResult = $false
                        }
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop - {1}/{2}] Done, exit condition result is {2}' -f $Name, $i, $Iterations, $ExitConditionResult)
                    }
                    else {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop - {1}/{2}] Done' -f $Name, $i, $Iterations)
                    }
                }
            }
        }
    }
    end {
        switch ($PSCmdlet.ParameterSetName) {
            'DoUntil' {
                if ((-not ($ExitConditionResult)) -and $StopWatch.Elapsed -ge $TimeSpan -and $PSBoundParameters.ContainsKey('IfTimeoutScript')) {
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Executing timeout script block' -f $Name)
                    . $IfTimeoutScript
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Done' -f $Name)
                }
                if (($ExitConditionResult) -and $PSBoundParameters.ContainsKey('IfSucceedScript')) {
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Executing success script block' -f $Name)
                    . $IfSucceedScript
                    Write-Verbose ('New-LoopAction: [{0}] [DoUntil] Done' -f $Name)
                }
                $StopWatch.Reset()
            }
            'ForLoop' {
                if ($PSBoundParameters.ContainsKey('ExitCondition')) {
                    if ((-not ($ExitConditionResult)) -and $i -ge $Iterations -and $PSBoundParameters.ContainsKey('IfTimeoutScript')) {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Executing timeout script block' -f $Name)
                        . $IfTimeoutScript
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Done' -f $Name)
                    }
                    elseif (($ExitConditionResult) -and $PSBoundParameters.ContainsKey('IfSucceedScript')) {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Executing success script block' -f $Name)
                        . $IfSucceedScript
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Done' -f $Name)
                    }
                }
                else {
                    if ($i -ge $Iterations -and $PSBoundParameters.ContainsKey('IfTimeoutScript')) {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Executing timeout script block' -f $Name)
                        . $IfTimeoutScript
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Done' -f $Name)

                    }
                    elseif ($i -lt $Iterations -and $PSBoundParameters.ContainsKey('IfSucceedScript')) {
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Executing success script block' -f $Name)
                        . $IfSucceedScript
                        Write-Verbose ('New-LoopAction: [{0}] [ForLoop] Done' -f $Name)
                    }
                }
            }
        }
        Write-Verbose ('New-LoopAction: [{0}] Finished' -f $Name)
    }
}

function WriteCMLogEntry {
    <#
    .SYNOPSIS
    Write to log file in CMTrace friendly format.
    .DESCRIPTION
    Half of the code in this function is Cody Mathis's. I added log rotation and some other bits, with help of Chris Dent for some sorting and regex. Should find this code on the WinAdmins GitHub repo for configmgr.
    .OUTPUTS
    Writes to $Folder\$FileName and/or standard output.
    .LINK
    https://github.com/winadminsdotorg/SystemCenterConfigMgr
    #>

    param (
        [parameter(Mandatory = $true, HelpMessage = 'Value added to the log file.', ValueFromPipeline)]
        [ValidateNotNullOrEmpty()]
        [string[]]$Value,
        [parameter(Mandatory = $false, HelpMessage = 'Severity for the log entry. 1 for Informational, 2 for Warning and 3 for Error.')]
        [ValidateNotNullOrEmpty()]
        [ValidateSet('1', '2', '3')]
        [string]$Severity = 1,
        [parameter(Mandatory = $false, HelpMessage = "Stage that the log entry is occuring in, log refers to as 'component'.")]
        [ValidateNotNullOrEmpty()]
        [string]$Component,
        [parameter(Mandatory = $true, HelpMessage = 'Name of the log file that the entry will written to.')]
        [ValidateNotNullOrEmpty()]
        [string]$FileName,
        [parameter(Mandatory = $true, HelpMessage = 'Path to the folder where the log will be stored.')]
        [ValidateNotNullOrEmpty()]
        [string]$Folder,
        [parameter(Mandatory = $false, HelpMessage = 'Set timezone Bias to ensure timestamps are accurate.')]
        [ValidateNotNullOrEmpty()]
        [int32]$Bias,
        [parameter(Mandatory = $false, HelpMessage = 'Maximum size of log file before it rolls over. Set to 0 to disable log rotation.')]
        [ValidateNotNullOrEmpty()]
        [int32]$MaxLogFileSize = 0,
        [parameter(Mandatory = $false, HelpMessage = 'Maximum number of rotated log files to keep. Set to 0 for unlimited rotated log files.')]
        [ValidateNotNullOrEmpty()]
        [int32]$MaxNumOfRotatedLogs = 0
    )
    begin {
        $LogFilePath = Join-Path -Path $Folder -ChildPath $FileName
    }
    # Determine log file location
    process {
        foreach ($_Value in $Value) {
            if ((([System.IO.FileInfo]$LogFilePath).Exists) -And ($MaxLogFileSize -ne 0)) {

                # Get log size in bytes
                $LogFileSize = [System.IO.FileInfo]$LogFilePath | Select-Object -ExpandProperty Length
        
                if ($LogFileSize -ge $MaxLogFileSize) {
        
                    # Get log file name without extension
                    $LogFileNameWithoutExt = $FileName -replace ([System.IO.Path]::GetExtension($FileName))
        
                    # Get already rolled over logs
                    $RolledLogs = "{0}_*" -f $LogFileNameWithoutExt
                    $AllLogs = Get-ChildItem -Path $Folder -Name $RolledLogs -File
        
                    # Sort them numerically (so the oldest is first in the list)
                    $AllLogs = $AllLogs | Sort-Object -Descending { $_ -replace '_\d+\.lo_$' }, { [Int]($_ -replace '^.+\d_|\.lo_$') }
                
                    ForEach ($Log in $AllLogs) {
                        # Get log number
                        $LogFileNumber = [int32][Regex]::Matches($Log, "_([0-9]+)\.lo_$").Groups[1].Value
                        switch (($LogFileNumber -eq $MaxNumOfRotatedLogs) -And ($MaxNumOfRotatedLogs -ne 0)) {
                            $true {
                                # Delete log if it breaches $MaxNumOfRotatedLogs parameter value
                                $DeleteLog = Join-Path $Folder -ChildPath $Log
                                [System.IO.File]::Delete($DeleteLog)
                            }
                            $false {
                                # Rename log to +1
                                $Source = Join-Path -Path $Folder -ChildPath $Log
                                $NewFileName = $Log -replace "_([0-9]+)\.lo_$",("_{0}.lo_" -f ($LogFileNumber+1))
                                $Destination = Join-Path -Path $Folder -ChildPath $NewFileName
                                [System.IO.File]::Copy($Source, $Destination, $true)
                            }
                        }
                    }
        
                    # Copy main log to _1.lo_
                    $NewFileName = "{0}_1.lo_" -f $LogFileNameWithoutExt
                    $Destination = Join-Path -Path $Folder -ChildPath $NewFileName
                    [System.IO.File]::Copy($LogFilePath, $Destination, $true)
        
                    # Blank the main log
                    $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, $false)
                    $StreamWriter.Close()
                }
            }
        
            # Construct time stamp for log entry
            switch -regex ($Bias) {
                '-' {
                    $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), $Bias)
                }
                Default {
                    $Time = [string]::Concat($(Get-Date -Format 'HH:mm:ss.fff'), '+', $Bias)
                }
            }
        
            # Construct date for log entry
            $Date = (Get-Date -Format 'MM-dd-yyyy')
        
            # Construct context for log entry
            $Context = $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)
        
            # Construct final log entry
            $LogText = [string]::Format('<![LOG[{0}]LOG]!><time="{1}" date="{2}" component="{3}" context="{4}" type="{5}" thread="{6}" file="">', $_Value, $Time, $Date, $Component, $Context, $Severity, $PID)
        
            # Add value to log file
            try {
                $StreamWriter = [System.IO.StreamWriter]::new($LogFilePath, 'Append')
                $StreamWriter.WriteLine($LogText)
                $StreamWriter.Close()
            }
            catch  {
                Write-Error $_ -ErrorAction $ErrorActionPreference
            }
        }
    }
}

function WriteScreenInfo {
    [CmdletBinding()]
    <#
    .SYNOPSIS
        Inspired by PSLog in the AutomatedLab module
        https://github.com/AutomatedLab/AutomatedLab/blob/c01e2458e38811ccc4b2c58e3f958d666c39d9b9/PSLog/PSLog.psm1
    #>

    Param(
        [Parameter(Mandatory, ValueFromPipeline)]
        [string[]]$Message,
        [Parameter(Mandatory)]
        [datetime]$ScriptStart,
        [Parameter()]
        [ValidateSet("Error", "Warning", "Info", "Verbose", "Debug")]
        [string]$Type = "Info",
        [Parameter()]
        [int32]$Indent = 0,
        [Parameter()]
        [Switch]$PassThru
    )
    begin {
        $Date = Get-Date
        $TimeString = "{0:d2}:{1:d2}:{2:d2}" -f $Date.Hour, $Date.Minute, $Date.Second
        $TimeDelta = $Date - $ScriptStart
        $TimeDeltaString = "{0:d2}:{1:d2}:{2:d2}" -f $TimeDelta.Hours, $TimeDelta.Minutes, $TimeDelta.Seconds
    }
    process {
        foreach ($Msg in $Message) {
            if ($PassThru.IsPresent) { Write-Output $Msg }
            $Msg = ("- " + $Msg).PadLeft(($Msg.Length) + ($Indent * 4), " ")
            $string = "[ {0} | {1} ] {2}" -f $TimeString, $TimeDeltaString, $Msg
            switch ($Type) {
                "Error" {
                    Write-Host $string -ForegroundColor Red
                }
                "Warning" {
                    Write-Host $string -ForegroundColor Yellow
                }
                "Info" {
                    Write-Host $string
                }
                "Debug" {
                    if ($DebugPreference -eq "Continue") { Write-Host $string -ForegroundColor Cyan }
                }
                "Verbose" {
                    if ($VerbosePreference -eq "Continue") { Write-Host $string -ForegroundColor Cyan }
                }
            }
        }
    }
}
#endregion

#region Public
function Get-CMSoftwareUpdates {
    <#
    .SYNOPSIS
        Retrieve all of the software updates available on a local or remote client.
    .DESCRIPTION
        Retrieve all of the software updates available on a local or remote client.

        This function is called by Invoke-CMSnowflakePatching.

        The software updates are retrieved from the CCM_SoftwareUpdate WMI class, including all its properties.
    .PARAMETER ComputerName
        Name of the remote system you wish to retrieve available software updates from. If omitted, it will execute on localhost.
    .PARAMETER Filter
        WQL query filter used to filter the CCM_SoftwareUpdate class. If omitted, the query will execute without a filter.
    .EXAMPLE
        Get-CMSoftwareUpdates -ComputerName 'ServerA' -Filter 'ArticleID = "5016627"'

        Queries remote system 'ServerA' to see if software update with article ID 5016627 is available. If nothing returns, the update is not available to install.
    .INPUTS
        This function does not accept input from the pipeline.
    .OUTPUTS
        Microsoft.Management.Infrastructure.CimInstance
    #>

    [CmdletBinding()]
    [OutputType([Microsoft.Management.Infrastructure.CimInstance])]
    param(
        [Parameter()]
        [String]$ComputerName,
        [Parameter()]
        [String]$Filter
    )

    $CimSplat = @{
        Namespace    = 'root\CCM\ClientSDK'
        ClassName    = 'CCM_SoftwareUpdate'
        ErrorAction  = 'Stop'
    }

    if ($PSBoundParameters.ContainsKey('ComputerName')) {
        $CimSplat['ComputerName'] = $ComputerName
    }
    

    if ($PSBoundParameters.ContainsKey('Filter')) {
        $CimSplat['Filter'] = $Filter
    }
    
    try {
        [CimInstance[]](Get-CimInstance @CimSplat)
    }
    catch {
        Write-Error $_ -ErrorAction $ErrorActionPreference
    }
}

function Invoke-CMSnowflakePatching {
    <#
    .SYNOPSIS
        Invoke software update installation for a ConfigMgr client, an array of clients, or by ConfigMgr collection.
    .DESCRIPTION
        Invoke software update installation for a ConfigMgr client, an array of clients, or by ConfigMgr collection.

        The function will attempt to install all available updates on a target system. By default it will not reboot or
        retry failed installations.

        You can pass a single, or array of, computer names, or you can specify a ConfigMgr collection ID. Alternatively,
        you can use the ChooseCollection switch which will present a searchable list of all ConfigMgr device collections
        to choose from in an Out-GridView window.

        If ComputerName, ChooseCollection, and CollectionId parameters are not used, the ChooseCollection is the default
        parameter set.

        If multiple ConfigMgr clients are in scope, all will be processed and monitored asyncronously using jobs. The
        function will not immediately return. It will wait until all jobs are no longer running.

        Progress will be written as host output to the console, and log file in the %temp% directory.

        An output pscustomobject will be returned at the end if either the ComputerName or CollectionId parameters
        were used. If the ChooseCollection switch was used, no output object is returned (progress will still be written
        to the host).
        
        There will be an output object per target client. It will contain properties such as result, updates installed,
        whether a pending reboot is required, and how many times a system rebooted and how
        many times software update installations were retried.

        A system can be allowed to reboot and retry multiple times with the AllowReboot or Attempts parameter (or both).

        It is recommended you read my blog post to understand the various ways in how you can use this function:
        https://adamcook.io/p/patching-snowflakes-with-configMgr-and-powerShell
    .PARAMETER ComputerName
        Name of the remote systems you wish to invoke software update installations on.
        This parameter cannot be used with the ChooseCollection or CollectionId parameters.
    .PARAMETER ChooseCollection
        A PowerShell Out-GridView window will appear, prompting you to choose a ConfigMgr device collection.
        All members of this collection will be patched.
        This parameter cannot be used with the ComputerName or CollectionId parameters.
    .PARAMETER CollectionId
        A ConfigMgr collection ID of whose members you intend to patch.
        All members of this collection will be patched.
        This parameter cannot be used with the ComputerName or ChooseCollection parameters.
    .PARAMETER AllowReboot
        If an update returns a soft or hard pending reboot, specifying this switch will allow the system to be rebooted
        after all updates have finished installing. By default, the function will not reboot the system(s).
        More often than not, reboots are required in order to finalise software update installation. Using this switch
        and allowing the system(s) to reboot if required ensures a complete patch cycle.
    .PARAMETER Attempts
        Specify the number of retries you would like to script to make when a software update install failure is detected.
        In other words, if software updates fail to install, and you specify 2 for the Attempts parameter, the script will
        attempts installation twice. The default value is 1.
    .PARAMETER UpdateNameFilter
        One or more strings used to filter the updates you want to invoke the installation of based on name.
        It is advised to either provide full or partial strings of the match you need. The function wraps wildcards around the filter.
        For example, if you provide '7-Zip', the function will search for available updates with '%7-Zip%'.
    .PARAMETER RebootTimeoutMins
        How long to wait for a host to become responsive again after reboot.
        This parameter is hidden from tab completion.
    .PARAMETER InstallUpdatesTimeoutMins
        How long to wait for a successful execution of the Software Update Scan Cycle after update install/reboot
        This parameter is hidden from tab completion.
    .PARAMETER SoftwareUpdateScanCycleTimeoutMins
        How long to wait for updates to begin installing after invoking them to begin installing
        This parameter is hidden from tab completion.
    .PARAMETER InvokeSoftwareUpdateInstallTimeoutMins
        How long to wait for installing software updates on a host
        This parameter is hidden from tab completion.
    .EXAMPLE
        Invoke-CMSnowflakePatching -ComputerName 'ServerA', 'ServerB' -AllowReboot

        Will invoke software update installation on 'ServerA' and 'ServerB' and reboot the systems if any updates return
        a soft or hard pending reboot.
    .EXAMPLE
        Invoke-CMSnowflakePatching -ChooseCollection -AllowReboot

        An Out-GridView dialogue will be preented to the user to choose a ConfigMgr device collection. All members of
        the collection will be targted for software update installation. They will be rebooted if any updates
        return a soft or hard pending reboot.
    .EXAMPLE
        Invoke-CMSnowflakePatching -CollectionId P0100016 -AllowReboot

        Will invoke software update installation on all members of the ConfigMgr device collection ID P0100016. They
        will be rebooted if any updates return a soft or hard pending reboot.
    .INPUTS
        This function does not accept input from the pipeline.
    .OUTPUTS
        PSCustomObject
    #>

    [CmdletBinding(DefaultParameterSetName = 'ByChoosingConfigMgrCollection')]
    [OutputType([PSCustomObject], ParameterSetName=('ByComputerName','ByConfigMgrCollectionId'))]
    param(
        [Parameter(Mandatory, 
            ValueFromPipeline, 
            ValueFromPipelineByPropertyName,
            ParameterSetName = 'ByComputerName')]
        [String[]]$ComputerName,

        [Parameter(ParameterSetName = 'ByChoosingConfigMgrCollection')]
        [Switch]$ChooseCollection,

        [Parameter(Mandatory,
            ParameterSetName = 'ByConfigMgrCollectionId')]
        [String]$CollectionId,

        [Parameter()]
        [Switch]$AllowReboot,

        [Parameter()]
        [ValidateScript({
            if ($_ -le 0) {
                throw 'Attempts cannot be 0 or less. If you do not want any retry attempts, omit the parameter.'
            }
            else {
                $true
            }
        })]
        [Int]$Attempts = 1,

        [Parameter()]
        [ValidateScript({
            if ($_ -match '%') {
                throw 'String can not contain character %'
            }
            else {
                $true
            }
        })]
        [String[]]$UpdateNameFilter,

        [Parameter(DontShow)]
        [Int]$RebootTimeoutMins = 120,

        [Parameter(DontShow)]
        [Int]$InstallUpdatesTimeoutMins = 720,

        [Parameter(DontShow)]
        [Int]$SoftwareUpdateScanCycleTimeoutMins = 15,

        [Parameter(DontShow)]
        [Int]$InvokeSoftwareUpdateInstallTimeoutMins = 5
    )

    #region Define PSDefaultParameterValues, other variables, and enums
    $JobId = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss'
    $StartTime = Get-Date

    $PSDefaultParameterValues = @{
        'WriteCMLogEntry:Bias'                 = (Get-CimInstance -ClassName Win32_TimeZone | Select-Object -ExpandProperty Bias)
        'WriteCMLogEntry:Folder'               = $env:temp
        'WriteCMLogEntry:FileName'             = 'Invoke-CMSnowflakePatching_{0}.log' -f $JobId
        'WriteCMLogEntry:MaxLogFileSize'       = 5MB
        'WriteCMLogEntry:MaxNumOfRotatedLogs'  = 0
        'WriteCMLogEntry:ErrorAction'          = $ErrorActionPreference
        'WriteScreenInfo:ScriptStart'          = $StartTime
    }
    #endregion

    'Starting' | WriteScreenInfo -PassThru | WriteCMLogEntry -Component 'Initialisation'

    WriteCMLogEntry -Value ('ParameterSetName: {0}' -f $PSCmdlet.ParameterSetName) -Component 'Initialisation'
    WriteCMLogEntry -Value ('ForceReboot: {0}' -f $AllowReboot.IsPresent) -Component 'Initialisation'
    WriteCMLogEntry -Value ('Attempts: {0}' -f $Attempts) -Component 'Initialisation'

    if ($PSCmdlet.ParameterSetName -ne 'ByComputerName') {
        $PSDrive = (Get-PSDrive -PSProvider CMSite -ErrorAction 'Stop')[0]
        $CMDrive = '{0}:\' -f $PSDrive.Name
        Push-Location $CMDrive

        switch ($PSCmdlet.ParameterSetName) {
            'ByChoosingConfigMgrCollection' {
                WriteCMLogEntry -Value 'Getting all device collections' -Component 'Initialisation'
                try {
                    $DeviceCollections = Get-CMCollection -CollectionType 'Device' -ErrorAction 'Stop'
                    WriteCMLogEntry -Value 'Success' -Component 'Initialisation'
                }
                catch {
                    'Failed to get device collections' | 
                        WriteScreenInfo -Type 'Error' -PassThru | 
                        WriteCMLogEntry -Severity 3 -Component 'Initialisation'
                    WriteCMLogEntry -Value $_.Exception.Message -Severity 3 -Component 'Initialisation'
                    Pop-Location 
                    $PSCmdlet.ThrowTerminatingError($_)
                }

                'Prompting user to choose a collection' | WriteScreenInfo -PassThru | WriteCMLogEntry -Component 'Initialisation'
                $Collection = $DeviceCollections | 
                    Select-Object Name, CollectionID, MemberCount, Comment | 
                    Out-GridView -Title 'Choose a Configuration Manager collection' -PassThru

                if (-not $Collection) {
                    'User did not choose a collection, quitting' | 
                        WriteScreenInfo -Indent 1 -Type 'Warning' -PassThru | 
                        WriteCMLogEntry -Severity 2 -Component 'Initialisation'
                    Pop-Location 
                    return
                }
                else {
                    'User chose collection {0}' -f $Collection.CollectionID | 
                        WriteScreenInfo -Indent 1 -PassThru | 
                        WriteCMLogEntry -Component 'Initialisation'
                }
            }
            'ByConfigMgrCollectionId' {
                'Getting collection {0}' -f $CollectionId | WriteScreenInfo -PassThru | WriteCMLogEntry -Component 'Initialisation'
                try {
                    $Collection = Get-CMCollection -Id $CollectionId -CollectionType 'Device' -ErrorAction 'Stop'
                    if ($null -eq $Collection) {
                        $Exception = [System.ArgumentException]::new('Did not find a device collection with ID {0}' -f $CollectionId)
                        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                            $Exception,
                            0,
                            [System.Management.Automation.ErrorCategory]::ObjectNotFound,
                            $ComputerName
                        )
                        throw $ErrorRecord                  
                    }
                    else {
                        'Success' | WriteScreenInfo -Indent 1 -PassThru | WriteCMLogEntry -Component 'Initialisation'
                    }
                }
                catch {
                    'Failed to get collection {0}' -f $CollectionId | 
                        WriteScreenInfo -Type 'Error' -PassThru | 
                        WriteCMLogEntry -Severity 3 -Component 'Initialisation'
                    WriteCMLogEntry -Value $_.Exception.Message -Severity 3 -Component 'Initialisation'
                    Pop-Location 
                    $PSCmdlet.ThrowTerminatingError($_)
                }
            }
        }
        
        'Getting collection members' | WriteScreenInfo -PassThru | WriteCMLogEntry -Component 'Initialisation'

        try {
            $CollectionMembers = Get-CMCollectionMember -CollectionId $Collection.CollectionID -ErrorAction 'Stop'
        }
        catch {
            'Failed to get collection members' -f $CollectionId | 
                WriteScreenInfo -Type 'Error' -PassThru | 
                WriteCMLogEntry -Severity 3 -Component 'Initialisation'
            WriteCMLogEntry -Value $_.Exception.Message -Severity 3 -Component 'Initialisation'
            Pop-Location 
            $PSCmdlet.ThrowTerminatingError($_)
        }

        'Number of members: {0}' -f @($CollectionMembers).Count | WriteScreenInfo -Indent 1 -PassThru | WriteCMLogEntry -Component 'Initialisation'
        
        Pop-Location    
    }
    else {
        $CollectionMembers = foreach ($Computer in $ComputerName) {
            [PSCustomObject]@{
                Name = $Computer
            }
        }
    }

    $Jobs = foreach ($Member in $CollectionMembers) {
        $StartJobSplat = @{
            Name                 = $Member.Name
            InitializationScript = { Import-Module 'PSCMSnowflakePatching' -ErrorAction 'Stop' }
            ArgumentList         = @(
                $Member.Name, 
                $AllowReboot.IsPresent, 
                $Attempts,
                (,$UpdateNameFilter),
                $InvokeSoftwareUpdateInstallTimeoutMins, 
                $InstallUpdatesTimeoutMins,
                $RebootTimeoutMins
            )
            ErrorAction          = 'Stop'
            ScriptBlock          = {
                param (
                    [String]$ComputerName,
                    [Bool]$AllowReboot,
                    [Int]$Attempts,
                    [String[]]$UpdateNameFilter,
                    [Int]$InvokeSoftwareUpdateInstallTimeoutMins,
                    [Int]$InstallUpdatesTimeoutMins,
                    [Int]$RebootTimeoutMins
                )

                $Module = Get-Module 'PSCMSnowflakePatching'

                

                if (-not [String]::IsNullOrWhiteSpace($UpdateNameFilter)) {
                    $Filter = 'ComplianceState = 0 AND (EvaluationState = 0 OR EvaluationState = 1 OR EvaluationState = 13) AND (Name LIKE "%{0}%")' -f
                        [String]::Join('%" OR Name LIKE "%', $UpdateNameFilter)
                }
                else {
                    $Filter = 'ComplianceState = 0 AND (EvaluationState = 0 OR EvaluationState = 1 OR EvaluationState = 13)'
                }

                $GetCMSoftwareUpdatesSplat = @{
                    ComputerName = $ComputerName
                    Filter       = $Filter
                    ErrorAction  = 'Stop'
                }
                [CimInstance[]]$UpdatesToInstall = Get-CMSoftwareUpdates @GetCMSoftwareUpdatesSplat
                        
                if ($UpdatesToInstall.Count -gt 0) {
                    $Iterations      = $Attempts
                    $RebootCounter   = 0
                    $AttemptsCounter = 0
                    $AllUpdates      = @{}

                    & $Module NewLoopAction -Iterations $Iterations -LoopDelay 30 -LoopDelayType 'Seconds' -ScriptBlock {
                        $AttemptsCounter++

                        if ($AttemptsCounter -gt 1) {
                            # Get a fresh collection of available updates to install because if some updates successfully installed _and_ failed
                            # in the last iteration then we will get an error about trying to install updates that are already installed, whereas
                            # it's just the failed ones we want to retry, or any other new updates that have all of a sudden became available since
                            # the last iteration
                            [CimInstance[]]$UpdatesToInstall = Get-CMSoftwareUpdates @GetCMSoftwareUpdatesSplat
                        }

                        # Keep track of all updates processed and only keeping their last state in WMI for reporting back the overal summary later
                        foreach ($Update in $UpdatesToInstall) {
                            $AllUpdates[$Update.UpdateID] = $Update
                        }

                        $InvokeCMSoftwareUpdateInstallSplat = @{
                            ComputerName                           = $ComputerName
                            Update                                 = $UpdatesToInstall
                            InvokeSoftwareUpdateInstallTimeoutMins = $InvokeSoftwareUpdateInstallTimeoutMins
                            InstallUpdatesTimeoutMins              = $InstallUpdatesTimeoutMins
                            ErrorAction                            = 'Stop'
                        }
                        [CimInstance[]]$Result = Invoke-CMSoftwareUpdateInstall @InvokeCMSoftwareUpdateInstallSplat
                        
                        if ($AllowReboot -And $Result.EvaluationState -match '^8$|^9$|^10$') {
                            $RebootCounter++

                            Restart-Computer -ComputerName $ComputerName -Force -Wait -ErrorAction 'Stop'

                            & $Module NewLoopAction -LoopTimeout $RebootTimeoutMins -LoopTimeoutType 'Minutes' -LoopDelay 15 -LoopDelayType 'Seconds' -ScriptBlock {
                                # Wait for SMS Agent Host to startup and for relevant ConfigMgr WMI classes to become available
                            } -ExitCondition {
                                try {
                                    $Splat = @{
                                        ComputerName = $ComputerName
                                        ClassName    = 'Win32_Service'
                                        Filter       = 'Name = "ccmexec" OR Name = "winmgmt" OR Name = "netlogon"'
                                    }
                                    $ServicesState = Get-CimInstance @Splat

                                    $null = Get-CMSoftwareUpdates -ComputerName $ComputerName -ErrorAction 'Stop'
    
                                    if (
                                        $? -And
                                        $ServicesState.Count -eq 3 -And 
                                        ($ServicesState.State -eq 'Running').Count -eq 3
                                    ) {
                                        return $true
                                    }
                                }
                                catch {}
                            } -IfTimeoutScript {
                                $Exception = [System.TimeoutException]::new('Timeout while waiting for {0} to reboot' -f $ComputerName)
                                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                                    $Exception,
                                    0,
                                    [System.Management.Automation.ErrorCategory]::OperationTimeout,
                                    $ComputerName
                                )
                                $PSCmdlet.ThrowTerminatingError($ErrorRecord)                    
                            }
                            
                        }
                    } -ExitCondition {
                        if ($Result.EvaluationState -notmatch '^13$') {
                            # Don't bother doing ScanByUpdateSource if all the updates are in a failed state
                            & $Module NewLoopAction -LoopTimeout $SoftwareUpdateScanCycleTimeoutMins -LoopTimeoutType 'Minutes' -LoopDelay 1 -LoopDelayType 'Seconds' -ScriptBlock { } -ExitCondition {
                                try {
                                    Start-CMClientAction -ComputerName $ComputerName -ScheduleId 'ScanByUpdateSource' -ErrorAction 'Stop'
                                    Start-Sleep -Seconds 180
                                    Start-CMClientAction -ComputerName $ComputerName -ScheduleId 'ScanByUpdateSource' -ErrorAction 'Stop'
                                    Start-Sleep -Seconds 180
                                    return $true
                                }
                                catch {
                                    if ($_.FullyQualifiedErrorId -match '0x80070005|0x80041001' -Or $_.Exception.Message -match '0x80070005|0x80041001') {
                                        # If ccmexec service hasn't started yet, or is still starting, access denied is thrown
                                        return $false
                                    }
                                    else {
                                        $PSCmdlet.ThrowTerminatingError($_)
                                    }
                                }
                            } -IfTimeoutScript {
                                $Exception = [System.TimeoutException]::new('Timeout while trying to invoke Software Update Scan Cycle for {0}' -f $ComputerName)
                                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                                    $Exception,
                                    0,
                                    [System.Management.Automation.ErrorCategory]::OperationTimeout,
                                    $ComputerName
                                )
                                $PSCmdlet.ThrowTerminatingError($ErrorRecord)
                            }
                        }

                        $Filter = 'UpdateID = "{0}"' -f [String]::Join('" OR UpdateID = "', $UpdatesToInstall.UpdateID)
                        
                        try {
                            $LatestUpdates = Get-CMSoftwareUpdates -ComputerName $ComputerName -Filter $Filter -ErrorAction 'Stop'
                        }
                        catch {
                            $PSCmdlet.ThrowTerminatingError($_)
                        }

                        # Keep track of all updates processed and only keeping their last state in WMI for reporting back the overal summary later
                        foreach ($Update in $AllUpdates.Values) {
                            if ($LatestUpdates.UpdateID -contains $Update.UpdateID) {
                                # If update is still in WMI, update its state/error code in the hashtable tracker for reporting summary later on
                                $x = $LatestUpdates | Where-Object { $_.UpdateID -eq $Update.UpdateID }
                                $Update.EvaluationState = [EvaluationState]$x.EvaluationState
                                $Update.ErrorCode       = $x.ErrorCode
                                $Update | Add-Member -MemberType NoteProperty -Name 'EvaluationStateStr' -Value ([EvaluationState]$x.EvaluationState).ToString() -Force
                            }
                            else {
                                # If the update is no longer in WMI, assume its state is installed and force EvaluationState to be 12 for reporting summary later on
                                $Update.EvaluationState = [EvaluationState]12
                                $Update.ErrorCode       = 0
                                $Update | Add-Member -MemberType NoteProperty -Name 'EvaluationStateStr' -Value ([EvaluationState]12).ToString() -Force
                            }
                        }

                        switch ($AllowReboot) {
                            $true {
                                # If updates are successfully installed, they will no longer appear in WMI
                                if ($LatestUpdates.Count -eq 0) { 
                                    return $true
                                }
                                else {
                                    return $false 
                                }
                            }
                            $false {
                                # Don't want anything other than pending hard/soft reboot, or installed
                                # Ideally, the update(s) should no longer be present in WMI if they're installed w/o reboot required,
                                # or be in a state of pending reboot which is OK
                                $NotWant = '^{0}$' -f ([String]::Join('$|^', 0..7+10+11+13..23))
                                if (@($LatestUpdates.EvaluationState -match $NotWant).Count -eq 0) { 
                                    return $true 
                                } else { 
                                    # If this occurs, the iterations on the loop will exceed and the IfTimeoutScript script block will be invoked,
                                    # thus reporting back one or more updates failed
                                    return $false 
                                }
                            }
                        }
                        
                    } -IfTimeoutScript {
                        [PSCustomObject]@{
                            PSTypeName       = 'PSCMSnowflakePatchingResult'
                            ComputerName     = $ComputerName
                            OperatingSystem  = & $Module GetOS -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                            PingResponse     = Test-Connection -ComputerName $ComputerName -Count 3 -Quiet
                            LastBootUpTime   = & $Module GetBootTime -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                            Result           = 'Failure'
                            Updates          = $AllUpdates.Values | Select-Object -Property @(
                                "Name"
                                "ArticleID"
                                @{Name = 'EvaluationState'; Expression = { $_.EvaluationStateStr }}
                                "ErrorCode"
                            )
                            IsPendingReboot  = $LatestUpdates.EvaluationState -match '^8$|^9$' -as [bool]
                            NumberOfReboots  = $RebootCounter
                            NumberOfAttempts = $AttemptsCounter
                        }
                    } -IfSucceedScript {
                        [PSCustomObject]@{
                            PSTypeName       = 'PSCMSnowflakePatchingResult'
                            ComputerName     = $ComputerName
                            OperatingSystem  = & $Module GetOS -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                            PingResponse     = Test-Connection -ComputerName $ComputerName -Count 3 -Quiet
                            LastBootUpTime   = & $Module GetBootTime -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                            Result           = 'Success'
                            Updates          = $AllUpdates.Values | Select-Object Name, ArticleID
                            IsPendingReboot  = $LatestUpdates.EvaluationState -match '^8$|^9$' -as [bool]
                            NumberOfReboots  = $RebootCounter
                            NumberOfAttempts = $AttemptsCounter
                        }
                    }
                }
                else {
                    [PSCustomObject]@{
                        PSTypeName      = 'PSCMSnowflakePatchingResult'
                        ComputerName    = $ComputerName
                        OperatingSystem = & $Module GetOS -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                        PingResponse    = Test-Connection -ComputerName $ComputerName -Count 3 -Quiet
                        LastBootUpTime  = & $Module GetBootTime -ComputerName $ComputerName -ErrorAction 'SilentlyContinue'
                        Result          = 'n/a'
                        Updates         = $null
                        IsPendingReboot = $false
                        NumberOfReboots = 0
                        NumberOfAttempts = 0
                    }
                }
            }
        }

        'Creating an async job to patch{0} {1}' -f $(if ($AllowReboot) { ' and reboot'} else { }), $Member.Name | 
            WriteScreenInfo -PassThru | 
            WriteCMLogEntry -Component 'Jobs'

        try {
            Start-Job @StartJobSplat
            'Success' | WriteScreenInfo -Indent 1 -PassThru | WriteCMLogEntry -Component 'Jobs'
        } catch {
            'Failed to create job' | WriteScreenInfo -Indent 1 -Type 'Error' -PassThru| WriteCMLogEntry -Component 'Jobs'
            WriteCMLogEntry -Value $_.Exception.Message -Severity 3 -Component 'Jobs'
            Write-Error $_ -ErrorAction $ErrorActionPreference
        }
    }

    if ($Jobs -And ($Jobs -is [Object[]] -Or $Jobs -is [System.Management.Automation.Job])) {
        'Waiting for updates to finish installing{0}for {1} hosts' -f $(if ($AllowReboot) { ' and rebooting '} else { ' ' }), $Jobs.Count |
            WriteScreenInfo -PassThru |
            WriteCMLogEntry -Component 'Patching'

        $CompletedJobs = [System.Collections.Generic.List[String]]@()
        $FailedJobs    = [System.Collections.Generic.List[String]]@()
        
        $Result = do {
            foreach ($_Job in $Jobs) {
                $Change = $false
                switch ($true) {
                    ($_Job.State -eq 'Completed' -And $CompletedJobs -notcontains $_Job.Name) {
                        $CompletedJobs.Add($_Job.Name)
                        $Data = $_Job | Receive-Job -Keep
                        $TimeSpan = New-TimeSpan -Start $_Job.PSBeginTIme -End $_Job.PSEndTime
                        $Data | Add-Member -MemberType NoteProperty -Name 'TotalTime' -Value $TimeSpan -PassThru

                        switch ($Data.Result) {
                            'n/a' {
                                '{0} did not install any updates as none were available' -f $_Job.Name |
                                    WriteScreenInfo -PassThru | 
                                    WriteCMLogEntry -Component 'Patching'
                            }
                            'Success' {
                                '{0} rebooted {1} times and successfully installed:' -f $_Job.Name, $Data.NumberOfReboots |
                                    WriteScreenInfo -PassThru |
                                    WriteCMLogEntry -Component 'Patching'

                                foreach ($item in $Data.Updates) {
                                    $item.Name |
                                        WriteScreenInfo -Indent 1 -PassThru |
                                        WriteCMLogEntry -Component 'Patching'
                                }
                            }
                            'Failure' { 
                                '{0} failed to install one or more updates:' -f $_Job.Name |
                                WriteScreenInfo -Type 'Error' -PassThru |
                                WriteCMLogEntry -Component 'Patching' -Severity 3

                                foreach ($item in $Data.Updates) {
                                    'Update "{0}" finished with evaluation state "{1}" and exit code {2}' -f $item.Name, 
                                                                                                             $item.EvaluationState,
                                                                                                             $item.ErrorCode |
                                        WriteScreenInfo -Indent 1 -PassThru |
                                        WriteCMLogEntry -Component 'Patching'
                                }
                            }
                        }

                        if ($Data.IsPendingReboot) {
                            '{0} has one or more updates pending a reboot' -f $_Job.Name |
                                WriteScreenInfo -Type 'Warning' -PassThru |
                                WriteCMLogEntry -Component 'Patching' -Severity 2
                        }

                        $Change = $true
                    }
                    ($_Job.State -eq 'Failed' -And $FailedJobs -notcontains $_Job.Name) {
                        $FailedJobs.Add($_Job.Name)
                        '{0} (job ID {1}) failed because: {2}' -f $_Job.Name, $_Job.Id, $_Job.ChildJobs[0].JobStateInfo.Reason |
                            WriteScreenInfo -Indent 1 -Type 'Error' -PassThru |
                            WriteCMLogEntry -Severity 3 -Component 'Patching'
                        $Change = $true
                    }
                    $Change {
                        $RunningJobs = @($Jobs | Where-Object { $_.State -eq 'Running' }).Count
                        if ($RunningJobs -ge 1) {
                            'Waiting for {0} hosts' -f $RunningJobs |
                                WriteScreenInfo -PassThru |
                                WriteCMLogEntry -Component 'Patching'
                        }
                    }
                }
            }
        } until (
            $Jobs.Where{$_.State -eq 'Running'}.Count -eq 0 -And 
            (
                ($CompletedJobs.Count -eq $Jobs.Where{$_.State -eq 'Completed'}.Count -And $CompletedJobs.Count -gt 0) -Or
                ($FailedJobs.Count -eq $Jobs.Where{$_.State -eq 'Failed'}.Count -And $FailedJobs.Count -gt 0)
            )
        )
    }

    'Finished' | WriteScreenInfo -ScriptStart $StartTime -PassThru | WriteCMLogEntry -Component 'Deinitialisation'

    if ($PSCmdlet.ParameterSetName -eq 'ByChoosingConfigMgrCollection') {
        Write-Host 'Press any key to quit'
        [void][System.Console]::ReadKey($true)
    }
    else {
        $Result
    }

}

function Invoke-CMSoftwareUpdateInstall {
    <#
    .SYNOPSIS
        Initiate the installation of available software updates for a local or remote client.
    .DESCRIPTION
        Initiate the installation of available software updates for a local or remote client.

        This function is called by Invoke-CMSnowflakePatching.

        After installation is complete, regardless of success or failure, a CimInstance object from the CCM_SoftwareUpdate
        class is returned with the update(s) final state.

        The function processes syncronously, therefore it waits until the installation is complete.

        The function will timeout by default after 5 minutes waiting for the available updates to begin downloading/installing,
        and 120 minutes of waiting for software updates to finish installing. These timeouts are configurable via parameters
        InvokeSoftwareUpdateInstallTimeoutMins and InstallUpdatesTimeoutMins respectively.
    .PARAMETER ComputerName
        Name of the remote system you wish to invoke the software update installation on. If omitted, localhost will be targetted.
    .PARAMETER Update
        A CimInstance object, from the CCM_SoftwareUpdate class, of the updates you wish to invoke on the target system.

        Use the Get-CMSoftwareUpdates function to get this object for this parameter.
    .PARAMETER InvokeSoftwareUpdateInstallTimeoutMins
        Number of minutes to wait for all updates to change state to downloading/installing, before timing out and throwing an exception.
    .PARAMETER InstallUpdatesTimeoutMins
        Number of minutes to wait for all updates to finish installing, before timing out and throwing an exception.
    .EXAMPLE
        $Updates = Get-CMSoftwareUpdates -ComputerName 'ServerA' -Filter 'ComplianceState = 0'; Invoke-CMSoftwareUpdateInstall -ComputerName 'ServerA' -Updates $Updates

        The first command retrieves all available software updates from 'ServerA', and the second command initiates the software update install on 'ServerA'.

        The default timeout values apply: 5 minutes of waiting for updates to begin downloading/installing, and 120 minutes waiting for updates to finish installing,
        before an exception is thrown.
    .INPUTS
        This function does not accept input from the pipeline.
    .OUTPUTS
        Microsoft.Management.Infrastructure.CimInstance
    #>

    [CmdletBinding()]
    [OutputType([Microsoft.Management.Infrastructure.CimInstance])]
    param(
        [Parameter()]
        [String]$ComputerName,
        [Parameter(Mandatory)]
        [CimInstance[]]$Update,
        [Parameter()]
        [Int]$InvokeSoftwareUpdateInstallTimeoutMins = 5,
        [Parameter()]
        [Int]$InstallUpdatesTimeoutMins = 120
    )

    NewLoopAction -Name 'Initiate software update install' -LoopTimeout $InvokeSoftwareUpdateInstallTimeoutMins -LoopTimeoutType 'Minutes' -LoopDelay 5 -LoopDelayType 'Seconds' -ScriptBlock {
        try {
            $CimSplat = @{
                Namespace    = 'root\CCM\ClientSDK'
                ClassName    = 'CCM_SoftwareUpdatesManager'
                Name         = 'InstallUpdates'
                Arguments    = @{
                    CCMUpdates = [CimInstance[]]$Update
                }
                ErrorAction  = 'Stop'
            }

            if (-not [String]::IsNullOrWhiteSpace($ComputerName)) {
                $Options = New-CimSessionOption -Protocol 'DCOM'
                $CimSplat['CimSession'] = New-CimSession -ComputerName $ComputerName -SessionOption $Options -ErrorAction 'Stop'
            }

            $Result = Invoke-CimMethod @CimSplat

            if (-not [String]::IsNullOrWhiteSpace($ComputerName)) {
                Remove-CimSession $CimSplat['CimSession'] -ErrorAction 'Stop'
            }

            if ($Result.ReturnValue -ne 0) {
                $Exception = [System.Exception]::new('Failed to invoke software update(s) install, return code was {0}' -f $Result.ReturnValue)
                $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
                    $Exception,
                    $Result.ReturnValue,
                    [System.Management.Automation.ErrorCategory]::InvalidResult,
                    $ComputerName
                )
                throw $ErrorRecord
            }
        }
        catch {
            if ($_.FullyQualifiedErrorId -notmatch '0x80041001|0x80070005|0x87d00272' -Or $_.Exception.Message -notmatch '0x80041001|0x80070005|0x87d00272') {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    } -ExitCondition {
        try {
            $Splat = @{
                Filter       = 'UpdateID = "{0}"' -f [String]::Join('" OR UpdateID = "', $Update.UpdateID)
                ErrorAction  = 'Stop'
            }

            if (-not [String]::IsNullOrWhiteSpace($ComputerName)) {
                $Splat['ComputerName'] = $ComputerName
            }

            $LatestUpdates = Get-CMSoftwareUpdates @Splat
            if ($LatestUpdates.EvaluationState -match '^2$|^3$|^4$|^5$|^6$|^7$') { return $true }
        }
        catch {
            if ($_.FullyQualifiedErrorId -match '0x80041001|0x80070005' -Or $_.Exception.Message -match '0x80041001|0x80070005') {
                return $false
            }
            else {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    } -IfTimeoutScript {
        $Exception = [System.TimeoutException]::new('Timeout while trying to initiate update(s) install')
        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
            $Exception,
            $null,
            [System.Management.Automation.ErrorCategory]::OperationTimeout,
            $ComputerName
        )
        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
    }

    NewLoopAction -Name 'Installing software updates' -LoopTimeout $InstallUpdatesTimeoutMins -LoopTimeoutType 'Minutes' -LoopDelay 15 -LoopDelayType 'Seconds' -ScriptBlock {
        # Until all triggered updates are no longer in a state of downloading/installing
    } -ExitCondition {
        try {
            $Splat = @{
                Filter       = 'UpdateID = "{0}"' -f [String]::Join('" OR UpdateID = "', $Update.UpdateID)
                ErrorAction  = 'Stop'
            }

            if (-not [String]::IsNullOrWhiteSpace($ComputerName)) {
                $Splat['ComputerName'] = $ComputerName
            }

            $LastState = Get-CMSoftwareUpdates @Splat
            $x = $LastState.EvaluationState -match '^2$|^3$|^4$|^5$|^6$|^7$|^11$'
            # -match can return bool false if there's no match and there's only 1 update in $LastState
            $x.Count -eq 0 -Or -not $x
        }
        catch {
            if ($_.FullyQualifiedErrorId -match '0x80041001|0x80070005' -Or $_.Exception.Message -match '0x80041001|0x80070005') {
                return $false
            }
            else {
                $PSCmdlet.ThrowTerminatingError($_)
            }
        }
    } -IfTimeoutScript {
        $Exception = [System.TimeoutException]::new('Timeout while installing update(s)')
        $ErrorRecord = [System.Management.Automation.ErrorRecord]::new(
            $Exception,
            $null,
            [System.Management.Automation.ErrorCategory]::OperationTimeout,
            $ComputerName
        )
        $PSCmdlet.ThrowTerminatingError($ErrorRecord)
    } -IfSucceedScript {
        $LastState
    }
}

function Start-CMClientAction {
    <#
    .SYNOPSIS
        Invoke a Configuration Manager client action on a local or remote client, see https://docs.microsoft.com/en-us/mem/configmgr/develop/reference/core/clients/client-classes/triggerschedule-method-in-class-sms_client.
    .DESCRIPTION
        Invoke a Configuration Manager client action on a local or remote client, see https://docs.microsoft.com/en-us/mem/configmgr/develop/reference/core/clients/client-classes/triggerschedule-method-in-class-sms_client.

        This function is called by Invoke-CMSnowflakePatching.
    .PARAMETER ComputerName
        Name of the remote system you wish to invoke this action on. If omitted, it will execute on localhost.
    .PARAMETER ScheduleId
        Name of a schedule ID to invoke, see https://docs.microsoft.com/en-us/mem/configmgr/develop/reference/core/clients/client-classes/triggerschedule-method-in-class-sms_client.

        Tab complete to cycle through all the possible options, however the names are the same as per the linked doc but with spaces removed.
    .EXAMPLE
        Start-CMClientAction -ScheduleId ScanByUpdateSource

        Will asynchronous start the Software Update Scan Cycle action on localhost.
    .INPUTS
        This function does not accept input from the pipeline.
    .OUTPUTS
        This function does not output any object to the pipeline.
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [String]$ComputerName,
        [Parameter(Mandatory)]
        [TriggerSchedule]$ScheduleId
    )

    try {
        $CimSplat = @{
            Namespace    = 'root\CCM'
            ClassName    = 'SMS_Client'
            MethodName   = 'TriggerSchedule'
            Arguments    = @{
                sScheduleID = '{{00000000-0000-0000-0000-{0}}}' -f $ScheduleId.value__.ToString().PadLeft(12, '0')
            }
            ErrorAction  = 'Stop'
        }
        if ($PSBoundParameters.ContainsKey('ComputerName')) {
            $Options = New-CimSessionOption -Protocol DCOM -ErrorAction 'Stop'
            $CimSplat['CimSession'] = New-CimSession -ComputerName $ComputerName -SessionOption $Options -ErrorAction 'Stop'
        }
        
        $null = Invoke-CimMethod @CimSplat

        if ($PSBoundParameters.ContainsKey('ComputerName')) {
            Remove-CimSession $CimSplat['CimSession'] -ErrorAction 'Stop'
        }
    }
    catch {
        Write-Error $_ -ErrorAction $ErrorActionPreference
    }
}
#endregion