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 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 Retry 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 Retry 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 Retry parameter, the script will retry installation twice. .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 ($_ -lt 1) { throw 'Retry cannot be less than 1' } else { $true } })] [Int]$Retry ) #region Define PSDefaultParameterValues, other variables, and enums $JobId = Get-Date -Format 'yyyy-MM-dd_HH-mm-ss' $StartTime = Get-Date # NewLoopAction function is the primary looping function in this script # The below variables configure the function's timeout values where appropriate $RebootTimeoutMins = 120 # How long to wait for a host to become responsive again after reboot $SoftwareUpdateScanCycleTimeoutMins = 15 # How long to wait for a successful execution of the Software Update Scan Cycle after update install/reboot $InvokeSoftwareUpdateInstallTimeoutMins = 5 # How long to wait for updates to begin installing after invoking them to begin installing $InstallUpdatesTimeoutMins = 720 # How long to wait for installing software updates on a host $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 ('Retries: {0}' -f $Retry) -Component 'Initialisation' if ($PSCmdlet.ParameterSetName -ne 'ByComputerName') { $PSDrive = (Get-PSDrive -PSProvider CMSite)[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, $null, [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 } } } # 'Retry' actually means number of attempts, which will default to 1 # This is a juggling act of trying to simplify UX in the -Retry parameter name # versus my stubborn and tiredness to think of something better $Retry = if ($PSBoundParameters.ContainsKey('Retry')) { if ($Retry -eq 1) { 2 } else { $Retry } } else { 1 } $Jobs = foreach ($Member in $CollectionMembers) { $StartJobSplat = @{ Name = $Member.Name InitializationScript = { Import-Module 'PSCMSnowflakePatching' -ErrorAction 'Stop' } ArgumentList = @( $Member.Name, $AllowReboot.IsPresent, $Retry, $InvokeSoftwareUpdateInstallTimeoutMins, $InstallUpdatesTimeoutMins, $RebootTimeoutMins ) ErrorAction = 'Stop' ScriptBlock = { param ( [String]$ComputerName, [Bool]$AllowReboot, [Int]$Retry, [Int]$InvokeSoftwareUpdateInstallTimeoutMins, [Int]$InstallUpdatesTimeoutMins, [Int]$RebootTimeoutMins ) $Module = Get-Module 'PSCMSnowflakePatching' $GetCMSoftwareUpdatesSplat = @{ ComputerName = $ComputerName Filter = 'ComplianceState = 0 AND (EvaluationState = 0 OR EvaluationState = 1 OR EvaluationState = 13)' ErrorAction = 'Stop' } [CimInstance[]]$UpdatesToInstall = Get-CMSoftwareUpdates @GetCMSoftwareUpdatesSplat if ($UpdatesToInstall.Count -gt 0) { $Iterations = $Retry $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 { #$null = Get-CMSoftwareUpdates -ComputerName $ComputerName -ErrorAction 'Stop' #return $true $Splat = @{ ComputerName = $ComputerName ClassName = 'Win32_Service' Filter = 'Name = "ccmexec" OR Name = "winmgmt" OR Name = "netlogon"' } $ServicesState = Get-CimInstance @Splat if ( $ServicesState.Count -eq 3 -And ($ServicesState.State -eq 'Running').Count -eq 3 -And (Get-CMSoftwareUpdates -ComputerName $ComputerName -ErrorAction 'Stop') ) { return $true } } catch {} } -IfTimeoutScript { $Exception = [System.TimeoutException]::new('Timeout while waiting for {0} to reboot' -f $ComputerName) $ErrorRecord = [System.Management.Automation.ErrorRecord]::new( $Exception, $null, [System.Management.Automation.ErrorCategory]::OperationTimeout, $ComputerName ) $PSCmdlet.ThrowTerminatingError($ErrorRecord) } } } -ExitCondition { if ($Result.EvaluationState -notmatch '^8$|^9$|^13$') { # Don't bother doing ScanByUpdateSource if all the updates are in failed or pending reboot state # The point of this is to get updates out of WMI if they're successful/complete w/o pending reboot & $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, $null, [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 } } $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]@{ ComputerName = $ComputerName 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 NumberOfRetries = $AttemptsCounter - 1 } } -IfSucceedScript { [PSCustomObject]@{ ComputerName = $ComputerName Result = 'Success' Updates = $AllUpdates.Values | Select-Object Name, ArticleID IsPendingReboot = $LatestUpdates.EvaluationState -match '^8$|^9$' -as [bool] NumberOfReboots = $RebootCounter NumberOfRetries = $AttemptsCounter - 1 } } } else { [PSCustomObject]@{ ComputerName = $ComputerName Result = 'n/a' Updates = $null IsPendingReboot = $false NumberOfReboots = 0 NumberOfRetries = 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 $Data 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 |