Written by: Bill Powell, Technical Integrations Manager at ControlUp
In case you hadn’t noticed, ControlUp is releasing batches of scripts every month, generally on the last Sunday of the month (with some variation to allow for holidays). The batch of scripts for June was released on 2023-06-25.
The scripts fell into four groups:
Group 1 – Citrix Licensing
- Show Citrix CVAD License Details
Group 2 – Active Directory User Account Administration
- Get AD User Account Expiration Date
- Set AD User Account Expiration Date
- Get AD User Forced to Change Password
- Set AD User Force Change Password
- Show Enable or Disable AD User Inactive Accounts
Group 3 – Windows Event filtering
- Get Windows Events since Boot
Group 4 – Scripting using the new CUActions APIs
- List Available CUActions
- Power On Generic VM
- Force Power Off Generic VM
For this post, I’d like to focus on the last group – working with the new CUActions APIs. They’re pretty self-explanatory, so you can use them as they are.
I’d like to go deeper, though, because when I was writing them, I thought a lot about making them examples of how to write scripts for the CUActions APIs. I hesitate to say they’re ‘Best Practice’ – as that’s something that we only learn over time. But they do contain techniques that are worth looking at.
First, let’s look at ‘List Available CUActions’. The full script is over at https://www.controlup.com/script-library-posts/list-available-cuactions/
As is conventional, the script starts with a Help Block, which mentions right up front that these scripts are designed to be executed on a CU Monitor server. This is because they rely on a DLL that’s only installed as part of the CU Monitor installation.
So here’s technique #1 – how to make sure you load the correct DLL. Why does that matter? Because the DLL works in conjunction with the Monitor service, and if you run the DLL against a different version of the monitor (yes, people do have multiple versions of the monitor on their servers) then you’re likely to see odd behaviour. ControlUp only tests the DLL against that CU Monitor that it is installed with.
#region Load the version of the module to match the running monitor and check that it has the new features
There are several DLLs shipped with the monitor – we only need to load one DLL in this case, but if more than one should be required, they can be added to the DLLList parameter.
#region Load the version of the module to match the running monitor and check that it has the new features
function Get-MonitorDLLs {
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)][string[]]$DLLList
)
[int]$DLLsFound = 0
Get-CimInstance -Query "SELECT * from win32_Service WHERE Name = 'cuMonitor' AND State = 'Running'" | ForEach-Object {
$MonitorService = $_
if ($MonitorService.PathName -match '^(?<op>"{0,1})\b(?<text>[^"]*)\1$') {
$Path = $Matches.text
$MonitorFolder = Split-Path -Path $Path -Parent
$DLLList | ForEach-Object {
$DLLBase = $_
$DllPath = Join-Path -Path $MonitorFolder -ChildPath $DLLBase
if (Test-Path -LiteralPath $DllPath) {
$DllPath
$DLLsFound++
}
else {
throw "DLL $DllPath not found in running monitor folder"
}
}
}
}
if ($DLLsFound -ne $DLLList.Count) {
throw "cuMonitor is not installed or not running"
}
}
$AcceptableModules = New-Object System.Collections.ArrayList
try {
$DllsToLoad = Get-MonitorDLLs -DLLList @('ControlUp.PowerShell.User.dll')
$DllsToLoad | Import-Module
$DllsToLoad -replace "^.*\\",'' -replace "\.dll$",'' | ForEach-Object {$AcceptableModules.Add($_) | Out-Null}
}
catch {
$exception = $_
Write-Error "Required DLLs not loaded: $($exception.Exception.Message)"
}
if (-not ((Get-Command -Name 'Invoke-CUAction' -ErrorAction SilentlyContinue).Source) -in $AcceptableModules) {
Write-Error "ControlUp version 8.8 commands are not available on this system"
exit 0
}
#endregion
There are several DLLs shipped with the monitor – we only need to load one DLL in this case, but if more than one should be required, they can be added to the DLLList parameter.
Having found the required DLLs, the code then checks that ‘Invoke-CUAction’ has been loaded from one of the selected modules.
Running the script, you’ll see a list of actions that are available. Here’s an extract:
=======================================================================
Actions that operate on objects in the Sessions table
=======================================================================
Title Category IsSBA Description
----- -------- ----- -----------
With user approval Get Session Screenshot False Retrieves the active user session desktop screenshot
With user notification Get Session Screenshot False Retrieves the active user session desktop screenshot
Without notifying the user Get Session Screenshot False Retrieves the active user session desktop screenshot
Session Go to False Navigates to the process's session
Kill Policy Group Policy False Removes explorer Group Policy on the selected session
Reapply Group Policy Group Policy False Reapplies the user's group policy removed by the 'Kill Policy' action.
Refresh User Group Policy Group Policy False Refreshes the user group policy using the command 'gpupdate.exe /target:user'
Start Process In Session Processes False Starts a new process in the selected session
Import Registry Registry False Imports a registry key from a file....
Disconnect Session Remote Desktop Services False Disconnect a user session without notifing the user. If the selected target is an Account, then all the account sessions on the selected folders will be Disconnected.
LogOff Session Remote Desktop Services False Logs off a user session without notifing the user. If the selected target is an Account, then all the account sessions on the selected folders will be logged off.
Send Message Remote Desktop Services False Sends a message to the selected sessions.
Send Super Message Remote Desktop Services False Send a rich text message to the selected sessions, including graphics, text formatting and the ability to gain feedback from the user.
=======================================================================
Actions that operate on objects in the VMs table
=======================================================================
Title Category IsSBA Description
----- -------- ----- -----------
Force Power Off VM VM Power Management False Forcefully powers off the virtual machine
Force Reset VM VM Power Management False Forcefully resets the virtual machine
Power On VM VM Power Management False Powers on the virtual machine on the hypervisor infrastructure.
Restart Guest VM Power Management False Gracefully restarts the virtual machine
Shutdown Guest VM Power Management False Gracefully shuts down the virtual machine
Two things to note.
- The Actions are broken down by table. Actions act upon entities (rows) within a table, and each entity has an identifier – a key – which tells the action which entity to act on.
- Every Action is marked with an ‘IsSBA’ setting. If IsSBA is false, the action is handled by logic in the main ControlUp product. This is useful where (for example) we don’t know which hypervisor manages a particular VM. So if we were writing a ‘Power On VM’ script prior to CUActions, we would have to include code to manage each hypervisor type. Instead, the ‘Power On VM’ action is handled by the core product, which already has code to distinguish different hypervisors.
Let’s move on to the pair of scripts ‘Power On Generic VM’ and ‘Force Power Off Generic VM’ – paired because it makes testing easier to have both the switch on and switch off scripts to hand.
The scripts make use of two CUActions shown in the output above, and are intended for use either as a right-click action or invoked from a trigger.
As part of the testing process, I needed to find some VMs that were currently powered off, that I could power on. I also needed to find some powered-on VMs – with no sessions – that I could safely power off. So I wrote a script for that, too, but I didn’t release it. If there’s interest, I will bring it up to production standard and put it in the July release.
Back to those two Generic VM scripts. Generic, because they will work against any hypervisor that ControlUp supports. Here’s the code:
#region Perform CUAction on target
$ThisComputer = Get-CimInstance -ClassName Win32_ComputerSystem
Write-Output "Action $RequiredAction applied to $hostname from $($ThisComputer.Name)"
$Action = (Get-CUAvailableActions -DisplayName $RequiredAction | Where-Object {($_.Title -eq $RequiredAction) -and ($_.IsSBA -eq $false) -and ($_.Category -eq $RequiredActionCategory)})[0]
$Allrows = Invoke-CUQuery -Table $Action.Table -Fields * -Where "sName = '${hostname}'"
$Allrows.Data | ForEach-Object {
$VMTableRow = $_
$Result = Invoke-CUAction -ActionId $Action.Id `
-Table $Action.Table `
-RecordsGuids @($VMTableRow.key)
Write-Output "Action $RequiredAction applied to $hostname, result $($Result.Result)"
}
#endregion
The flow is identical for each of the scripts.
Step 1 – load the correct CUAction DLL, as above.
Step 2 – uniquely identify the CUAction by its name and category, yielding an Action object, which includes two fields that we will need for the Invoke-CUAction step:
Table: the name of the monitor table in which to search for the hostname
Id: the unique identifier that identifies the CUAction to be invoked
Step 3 – query the monitor table to find the row(s) identified by the VMs hostname. We’ll need the ‘key’ field from the row. There should only be a single VM for this use case. We’ll have a look at how to handle fetching multiple records in the next example.
Step 4 – invoke the CUAction. The action is asynchronous, so we don’t get a result back.
Job done.
Finally, let’s look at ‘Logoff Disconnected Sessions’. The idea is that we right click on an active session, and logoff all disconnected sessions for that user. The code is similar in most respects to the previous two scripts.
However, for this case, I assume that the user might have very many disconnected sessions, so I wrote code for extracting the records I’m interested in as a function (followed by the invocation). This is technique #2:
function Get-CUData {
[CmdletBinding()]
param (
[Parameter(Mandatory=$true)][string]$TableName,
[Parameter(Mandatory=$false)][string]$Where,
[Parameter(Mandatory=$false)][string[]]$FieldList,
[Parameter(Mandatory=$false)][int]$ChunkSize = 100
)
if ($FieldList.Count -lt 1) {
$FieldList = '*'
}
$ParamSplat = @{
Table = $TableName;
Fields = $FieldList;
Take = $ChunkSize;
Skip = 0;
}
if (-not [string]::IsNullOrWhiteSpace($Where)) {
$ParamSplat["Where"] = $Where
}
do {
$CUQueryResult = Invoke-CUQuery @ParamSplat
$CUQueryResult.Data
$ParamSplat["Skip"] += $chunkSize
} while ($ParamSplat["Skip"] -lt $CUQueryResult.Total)
}
[System.Collections.Generic.List[object]]$SessionList = Get-CUData -TableName $Action.Table `
-Where "sUserAccount = '$UserAccount'"
We use the Take and Skip parameters to pull all the data from the table in chunks of no more than 100 records (configurable). After each iteration of the do/while we increment the offset into the result dataset by the chunksize, until there are no more records left to return.
We now iterate over the records returned, performing the logoff action for each session that is in disconnected state (4).
$SessionList |
Where-Object {$_.eConnectState -eq 4} | # only disconnected sessions, omit for all sessions
ForEach-Object {
$Session = $_
Invoke-CUAction -ActionId $Action.Id `
-Table $Action.Table `
-RecordsGuids @($Session.key) | Out-Null
Write-Output "Logoff session for user $($Session.sUserAccount) on host $($Session.sServerName)"
}
#endregion
View Bill’s entire post, follow the thread, and/or comment inside the ControlUp Community.
Not a Member? Join Today!
Categories: Blog, ControlUp Scripts & Triggers