RPS Patching Script Framework
Last updated on August 26, 2021.
Document Status: Document Feature Complete as of August 26, 2021; PENDING EXTERNAL REVIEW.
This article introduces the Rapid Provisioning System (RPS) PowerShell script framework. This provides additional deployment options over the standard deployment features available in the RPS application.
Note
Users may see "patch" and "package" used interchangeably in the code and log outputs during this process.
Intended Audience
This document is intended for RPS patching roles, Lead Systems Integrators (LSI), Field Service Representatives (FSR), IT staff, and Developers. To use the framework, RPS users will need strong knowledge of PowerShell.
Use Cases
In many cases, standard RPS patching is not possible, for example:
- Installing patches on appliances where PowerShell cannot directly run.
- Example: a firewall appliance.
- Installing patches that also need custom pre-steps and post steps.
- Example: Configure custom registry values.
- Installing patches for a type of patch that does not have an installer.
- Example: Copying files to a location.
- Installing a patch package on a target system that does not have the Local Configuration Manager (LCM) enabled.
- Example: Install a patch on a system without the LCM by using script framework and setting property on that target to say
RunPackagesOnTms = $true
.
- Example: Install a patch on a system without the LCM by using script framework and setting property on that target to say
Now this article can be used to build PowerShell scripts using the required PowerShell functions, introduced below.
Script Framework Requirements
RPS users with RPS accounts need to:
- Log into a functioning RPS server.
- Ensure their user account has the RPS patching role.
- Launch Windows PowerShell ISE, for example.
- Build the script needed for the use case.
- Include the required functions defined in the table below.
- Run the script.
PowerShell Functions
Three required RPS-specific PowerShell functions must exist when building the script, as shown in the following table:
Function Name | Description | Return Type |
---|---|---|
Test-PackageResource | This function tests if the patch is in the desired state. | Must return a boolean. True for in-desired state and false for not-in-desired state. |
Set-PackageResource | This function installs or uninstalls. | None |
Get-ParameterMapping | This function helps with complex mappings using RPS-Mapped parameters. | Function will return a Hashtable for each custom parameter it needs to map. |
Next, the function Get-ParameterMapping is introduced.
Function: Get-ParameterMapping
function Get-ParameterMapping
{
return @{
DscEncryptionCertificate = @{
EntityClass = 'ResourceItem'
EntityType = 'Certificate'
Role = 'DscEncryption'
IsAssigned = $true
}
}
}
The PowerShell module can include other supporting functions and scripts as required.
Required Parameters
The Test-PackageResource and Set-PackageResource functions only have one required parameter, called Ensure. The Ensure parameter states if the patch should be 'Present' or 'Absent'. The two methods can have any other parameters that are required for the custom script to run using RPS-Mapped Parameters.
Note
For more information on RPS-Mapped Parameters, see the RPS article How to Configure RPS-Mapped Parameters.
Example Script Framework
The next example now includes the two other functions introduced above to patch VMWare ESXi.
- Test-PackageResource
- Set-PackageResource
$null = Import-Module -Name 'VMware.PowerCLI'
$null = Set-PowerCLIConfiguration -InvalidCertificateAction Ignore -Confirm:$false -ErrorAction SilentlyContinue -DefaultVIServerMode Multiple -ParticipateInCEIP $false -Scope Session
function Test-PackageResource
{
[CmdletBinding()]
Param
(
[Parameter(Mandatory = $true)]
[ValidateScript({[ipaddress]::Parse($_)})]
[string]
$IPAddress,
[Parameter(Mandatory = $true)]
[string]
$ComputerName,
[Parameter(Mandatory = $true)]
[PSCredential]
$LocalAdmin,
[Parameter(Mandatory = $true)]
[string]
$Ensure
)
Write-Verbose "Connecting to ESXi at $IPAddress"
$server = Connect-ViServer -Server $IPAddress -Credential $LocalAdmin -WarningAction SilentlyContinue -ErrorAction Stop
$virtualSwitches = Get-VirtualSwitch -Name 'PatchedVirtualSwitch' -ErrorAction SilentlyContinue
if($virtualSwitches)
{
if ($ensure -eq 'present')
{
return $true
}
else
{
return $false
}
}
if ($ensure -eq 'Absent')
{
return $true
}
return $false
Write-Verbose "Connected to ESXi at $IPAddress"
}
function Set-PackageResource
{
[CmdletBinding()]
Param
(
[Parameter(Mandatory = $true)]
[ValidateScript({[ipaddress]::Parse($_)})]
[string]
$IPAddress,
[Parameter(Mandatory = $true)]
[string]
$ComputerName,
[Parameter(Mandatory = $true)]
[PSCredential]
$LocalAdmin,
[Parameter(Mandatory = $true)]
[string]
$Ensure
)
$server = Connect-ViServer -Server $IPAddress -Credential $LocalAdmin -WarningAction SilentlyContinue -ErrorAction Stop
if ($ensure -eq 'Present')
{
New-VirtualSwitch -Server $server -Name 'PatchedVirtualSwitch' -ErrorAction SilentlyContinue
}
else
{
$vs = Get-VirtualSwitch -Server $server -Name 'PatchedVirtualSwitch'
$vs | Remove-VirtualSwitch -Server $server -ErrorAction SilentlyContinue -Confirm:$false
}
Write-Verbose "Connecting to ESXi at $IPAddress"
}
# Sample parameter mapping. We are not using any complex parameters in this script.
function Get-ParameterMapping
{
@{
DscEncryptionCertificate = @{
EntityClass = 'ResourceItem'
EntityType = 'Certificate'
Role = 'DscEncryption'
IsAssigned = $true
}
}
}
Windows Installer Patch (.msp) Example - Deprecated in 4.0
Important
This example is deprecated from the RPS 4.0 C:\ContentStore\Packaging folder, but will still exist in older RPS 3.1 servers. For RPS 4.0 servers, build .msp style patches using the RPS application.
How It Works
The example below will install .msp patches on any RPS Windows target, configured with the patch stream Desired State Configuration (DSC) feed resource.
The packaged script and .msp will be used to ensure the state of the target.
The PowerShell script will need to be constructed in a way to be able to test the current state and if necessary, set the configured state of the patch.
The number of different parameters needed to install the patch determines how complex the packaged script will be.
For most .msp installs, the only required parameter will be the 'Ensure' property. This parameter will control whether the patch is installed or uninstalled during set operations and used to test the current state for test operations.
Windows Installer Requirements
For scripted patch streams and patches to execute, clients and targets must be declared, with the RPS feed resource configured through Desired State Configuration (DSC).
Patch File Components
In order to use this framework to install a .msp style patch, the file layout should include each of the following components:
Note
Examples of the Package.RPS (manifest) file and the ExampleMspInstall.ps1 PowerShell script can be found below in this section.
ExampleMspInstall_1.0.0.zip
- Package.RPS
- ExampleMspInstall.ps1
ExampleProgram.msp
Figure 1: File Explorer view of the contents of an RPS .msp file layout.
1. Package.RPS
Use Notepad to create a patch manifest file below with a file name of Package.RPS.
The manifest file is used by the script to install the patch.
Later in the manifest, at the ExecutableName
tag, provide a name of .msp to be installed.
Both the manifest file and the script below must be zipped.
Note
The InstallerFileName should match the PowerShell script file name (ExampleMspInstall.ps1 in our example) and the Product Type should be set to "ScriptFramework".
The following is an example of a Package.RPS file that can be modified as required. Notice the use of the RPS cmdlet "Get-RpsAuditEntry" from the RPS-API module.
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema">
<PackageName>ExampleMspInstall</PackageName>
<PackageVersion>1.0.0</PackageVersion>
<Description>This is a test patch</Description>
<OsVersion>*</OsVersion>
<Architecture>x64</Architecture>
<OsType>Windows</OsType>
<MsCatalogProductName>ScriptFrameWorkTest1</MsCatalogProductName>
<MsCatalogTitle />
<MsCatalogId />
<Products />
<MsCatalogUpdateId />
<PackageClassification>General</PackageClassification>
<MsCatalogSupercededByKbIds />
<MsCatalogLinkUrls />
<UninstallArguments>/s</UninstallArguments>
<InstallArguments />
<SupressReboot>true</SupressReboot>
<ProductName>ExampleMspInstall</ProductName>
<ProductType>ScriptFramework</ProductType>
<ProductVersion>1.0.0</ProductVersion>
<ProductId>{null}</ProductId>
<InstallerFileName>ExampleMspInstall.ps1</InstallerFileName>
<ExecutableName>ExampleMspInstall.msp</ExecutableName>
</PackageManifest>
2. ExampleMspInstall.ps1
Use this example PowerShell script to install the .msp style patch using the manifest file defined above.
For the first two variables:
- Ensure that the file names and values match those defined in the manifest file above.
- These values will be used by the script to ensure either the program is installed or uninstalled from the local system.
Also take note of the two functions embedded in the script:
- Function Set-PackageResource
- Function Test-PackageResource
- The Get-ParameterMapping function is for future use and should always return an empty hashtable.
The following script is file-named ExampleMspInstall.ps1 that can be modified for actual use.
# Ensure the following two variables match the manifest file.
$Path = "$PSScriptRoot\Package.RPS"
$XPath = "/PackageManifest"
$script:xml = Select-Xml -Path $Path -XPath $Xpath
function Set-PackageResource
{
[CmdletBinding()]
param
(
[Parameter(Mandatory = $true)]
[ValidateSet('Present','Absent')]
[System.String]
$Ensure
)
try
{
if ($ensure -eq 'Present')
{
Write-Verbose $('Installing MSP {0}' -f $script:xml.Node.ExecutableName)
$arguments = '/p "{0}" /quiet /norestart' -f "$PSScriptRoot\$($script:xml.Node.ExecutableName)"
$result = Invoke-ManagedProcess -Program "$env:winDir\system32\msiexec.exe" -Arguments $arguments
}
else
{
Write-Verbose $('Uninstalling MSP {0}' -f $script:xml.Node.ExecutableName)
$packageSettings = Get-Package -Name $script:xml.Node.ProductName -RequiredVersion $script:xml.Node.ProductVersion -ProviderName msi,programs
$packageSettings = $packageSettings | Select-Object -First 1
$result = Invoke-ManagedProcess -Program $packageSettings.Metadata.Item('UninstallString') -Arguments '/s'
}
}
catch
{
Write-Warning "$_"
}
}
function Test-PackageResource
{
[CmdletBinding()]
param
(
[Parameter(Mandatory = $true)]
[ValidateSet('Present','Absent')]
[System.String]
$Ensure
)
try
{
Write-Verbose $('Testing state of MSP {0}' -f $script:xml.Node.ExecutableName)
$params = @{
Name = $script:xml.Node.ProductName
RequiredVersion = $script:xml.Node.ProductVersion
ProviderName = 'msi','Programs'
ErrorAction = 'SilentlyContinue'
}
$packageResult = Get-Package @params
if ($packageResult -and $Ensure -eq 'Present')
{
return $true
}
else
{
return $false
}
}
catch
{
return $false
}
}
function Get-ParameterMapping
{
return @{}
}
Installing Multiple Windows Hotfix (MSU) Files: Example
How It Works
The example below will install multiple .msu (hotfix) files on RPS Windows target(s).
- The PowerShell script.ps1 and .msu (hotfix) files will be used to ensure the state of each target.
- The PowerShell script.ps1 file will need to be constructed in a way to be able to test the current state and, if necessary, set the configured state of the patch.
The example consists of two pieces:
- An example Package.RPS manifest file that describes the Script Framework script that will kick off the patching process.
- An example script.ps1 PowerShell script that provides an example Script Framework script that installs multiple .msu (hotfix) files.
Note
Both of the above example files, the Package.RPS manifest file and the script.ps1 PowerShell script, will need to be modified to user requirements of the specific patching scenario.
The Package.RPS manifest should be modified so that it provides correct information about the bundle of .msu (hotfix) files that will be contained in the patch.
The script.ps1 PowerShell script should be modified by changing the value of the $patches variable at the top of the script to contain correct information about the .msu (hotfix) files that will be deployed. This includes correct information such as: ProductId (the KB number), Filepath (path to the msu within the patch ZIP - if it is at the root of the patch ZIP then the filename of the msu will suffice), and Ensure (Present or Absent, which designates whether the msu should be installed or removed).
After the Package.RPS manifest file and the script.ps1 have been modified and verified as correct, create a ZIP archive that contains:
- The Package.RPS manifest file
- The .msu (hotfix) files
- the script.ps1 PowerShell file
The ZIP file should follow the naming convention based on the values in the Package.RPS manifest file: <ProductName><ProductVersion>.zip
.
For example: "MyWindowsUpdates1.0.0.zip".
Windows Installer Requirements
For scripted patch streams and patches to execute, clients and targets must be declared, with the RPS feed resource configured through Desired State Configuration (DSC).
Patch Manifest - Package.RPS
<?xml version="1.0" encoding="utf-8"?>
<PackageManifest version="1.0">
<PackageName>MultipleMSU</PackageName>
<PackageVersion>1.0.0</PackageVersion>
<Description>This example patch will install multiple MSU files.</Description>
<OsVersion>*</OsVersion>
<Architecture>*</Architecture>
<OsType>Windows</OsType>
<PackageClassification>General</PackageClassification>
<UninstallArguments />
<InstallArguments />
<SuppressReboot>true</SuppressReboot>
<ProductName />
<ProductType>ScriptFramework</ProductType>
<ProductVersion />
<ProductId />
<InstallerFileName>multiple_msu.ps1</InstallerFileName>
</PackageManifest>
Script Framework Script - script.ps1
$patches = @(
@{
ProductId = "KB4601050" #Product ID (KB number) of the MSU/Hotfix
Filepath = Join-Path -Path $PSScriptRoot -ChildPath "KB4601050.msu" #fully qualified path to the MSU/Hotfix file
Ensure = "Present" #Present or Absent
},
@{
ProductId = "KB4601051" #Product ID (KB number) of the MSU/Hotfix
Filepath = Join-Path -Path $PSScriptRoot -ChildPath "KB4601051.msu" #fully qualified path to the MSU/Hotfix file
Ensure = "Present" #Present or Absent
},
@{
ProductId = "KB4601052" #Product ID (KB number) of the MSU/Hotfix
Filepath = Join-Path -Path $PSScriptRoot -ChildPath "KB4601052.msu" #fully qualified path to the MSU/Hotfix file
Ensure = "Present" #Present or Absent
}
)
function Test-PatchResource
{
[CmdletBinding()]
param()
Write-Verbose -Message 'Started testing patches'
$result = $true
foreach($patch in $patches)
{
Write-Verbose -Message "Testing state for patch $($patch.ProductId)"
$patchId = $patch.ProductId.ToLower().Replace("kb", "")
$hotfix = Get-Hotfix -Id $patchId -ErrorAction SilentlyContinue
if ($patch.Ensure -eq 'Present' -and $null -eq $hotfix)
{
Write-Verbose -Message "Patch $($patch.ProductId) is not in desired state. Patch is not installed"
$result = $false
}
elseif ($Patch.Ensure -eq 'Absent' -and $null -ne $hotfix)
{
Write-Verbose -Message "Patch $($patch.ProductId) is not in desired state. Patch is installed"
$result = $false
}
else
{
Write-Verbose -Message "$($patch.ProductId) is in desired state."
}
}
Write-Verbose -Message 'Finished testing patches'
return $result
}
function Set-PatchResource
{
[CmdletBinding()]
Param
(
)
Write-Verbose 'Started installing patches'
foreach($patch in $patches)
{
$hotfix = Get-Hotfix -Id $patch.ProductId -ErrorAction SilentlyContinue
if ($patch.Ensure -eq 'Present' -and $null -eq $hotfix)
{
Write-Verbose -Message "Installing $($patch.ProductId)"
$arguments = '"{0}" /quiet /norestart' -f $patch.Filepath
$result = Invoke-ManagedProcess -Program "$env:winDir\system32\wusa.exe" -Arguments $arguments
if ($result -eq 0)
{
Write-Verbose -Message "Finished installing $($patch.ProductId)"
}
else
{
Write-Verbose -Message "Error installing $($patch.ProductId)"
}
}
elseif ($patch.Ensure -eq 'Absent' -and $null -ne $hotfix)
{
Write-Verbose -Message "Uninstalling $($patch.ProductId)"
$updateId = $($patch.ProductId) -ireplace [regex]::Escape('KB'), ''
$arguments = '/uninstall /KB:{0} /quiet /norestart' -f $updateId
$result = Invoke-ManagedProcess -Program "$env:winDir\system32\wusa.exe" -Arguments $arguments
if ($result -eq 0)
{
Write-Verbose -Message "Finished uninstalling $($patch.ProductId)"
}
else
{
Write-Verbose -Message "Error uninstalling $($patch.ProductId)"
}
}
else
{
Write-Verbose -Message "$($patch.ProductId) is in desired state."
}
}
Write-Verbose 'Finished installing patches'
}
function Invoke-ManagedProcess
{
[Cmdletbinding()]
[OutputType([System.Uint32])]
param
(
[Parameter(Mandatory = $true)]
[System.String]
$Program,
[Parameter()]
[System.String]
$Arguments = '',
[Parameter()]
[System.Uint16]
$IdleTimeout = 60
)
$processInfo = New-Object System.Diagnostics.ProcessStartInfo
$processInfo.FileName = $Program
$processInfo.RedirectStandardError = $true
$processInfo.RedirectStandardOutput = $true
$processInfo.UseShellExecute = $false
$processInfo.Arguments = $Arguments
$managedProcess = New-Object System.Diagnostics.Process
$managedProcess.StartInfo = $processInfo
$managedProcess.Start() | Out-Null
while (Get-Process -Id $managedProcess.ID -ErrorAction SilentlyContinue)
{
Test-TaskTimeOut -Process $managedProcess -IdleTimeout $IdleTimeOut
}
return $managedProcess.ExitCode
}
function Test-TaskTimeOut
{
[CmdletBinding()]
param
(
[Parameter(Mandatory = $true)]
[PSObject]
$Process,
[Parameter()]
[System.Uint16]
$IdleTimeOut = 60
)
if ($null -eq $memUsageStack)
{
$script:memUsageStack = New-Object -TypeName System.Collections.Stack
}
if ($IdleTimeout -gt 0)
{
$lastMemUsageCount = Get-ProcessTreeMemoryUsage -ProcessId $Process.ID
$memUsageStack.Push($lastMemUsageCount)
if ($lastMemUsageCount -eq 0 -or ($null -ne ($memUsageStack.ToArray() | Where-Object -FilterScript { $_ -ne $lastMemUsageCount })))
{
if (-not (Get-Process -Id $Process.ID -ErrorAction SilentlyContinue))
{
break
}
$memUsageStack.Clear()
}
if ($memUsageStack.Count -gt $IdleTimeOut)
{
Stop-Process -Id $Process.ID
}
}
Start-Sleep -Second 1
}
function Get-ProcessTreeMemoryUsage
{
[CmdletBinding()]
[OutputType([System.Uint64])]
param
(
[Parameter(Mandatory = $true)]
[System.Uint32]
$ProcessId
)
$ReservedMemory = 0
$childProcessObject = Get-CimInstance Win32_Process -Filter "ParentProcessID=$ProcessId" -Verbose:$false
if ($childProcessObject)
{
foreach ($processObject in $childProcessObject)
{
if ($null -ne $processObject.ProcessID)
{
$currentProcess = Get-Process -ID $processObject.ProcessID -ErrorAction SilentlyContinue
$ReservedMemory += $currentProcess.PrivateMemorySize + $currentProcess.WorkingSet
$ReservedMemory += (Get-ProcessTreeMemoryUsage -ProcessId $processObject.ProcessID)
}
}
}
else
{
$currentProcess = Get-Process -ID $ProcessId -ErrorAction SilentlyContinue
if ($currentProcess)
{
$ReservedMemory += $currentProcess.PrivateMemorySize + $currentProcess.WorkingSet
}
else
{
$ReservedMemory = 0
}
}
return $ReservedMemory
}