Creating a PowerShell Code Factory
I am always on the look out for ways to get more done faster and with less effort. Especially when it comes to writing PowerShell code. I have lots of VSCode short cuts and make use of snippets and templates. However, there's always room for another technique.
Recently, this blog article came across my social media feeds. The author was using a scripting concept from LISP to auto-generate new functions. He had a custom PowerShell function to get customer information. That command need customer names. Rather, than forcing the user of his work to have to remember the customer names, he created a function with the the customer names as the function names. This way, the user could just type the customer name and get the information.
Here's the original implementation:
$clientFunctions = @(
"Contoso=3", "Fabrikam=14", "IGZX=2", "ABCD=8"
)
foreach ($client in $clientFunctions) {
$clientdata = $client -split '='
$clientName = $clientdata[0]
$clientId = $clientdata[1]
Invoke-Expression -Command @"
function $($clientName) {
[CmdletBinding()]
param (
[parameter()][switch]`$Update,
[parameter()][switch]`$UpdateCancel,
[parameter()][switch]`$Detailed
)
if (`$Update.IsPresent) { Get-CustomerRecords -ClientID $($clientId) -Action Update }
elseif (`$UpdateCancel.IsPresent) { Get-CustomerRecords -ClientID $($clientId) -Action Cancel }
elseif (`$Detailed.IsPresent) { Get-CustomerRecords -Client $($clientId) }
else { Get-CustomerRecords -ClientID $($clientId) | Select-Object * -ExcludeProperty Client | Format-Table -AutoSize }
}
"@
}
This code needs to be run in a PowerShell session to create the functions.
You might be thinking, "But what about naming standards?". This comes back to question you should always ask, "Who will be using my code and what are there expectations?". In this situation, the end-user is a non-technical, or at least not a PowerShell user. They need an easy to use command and aren't likely going to customize the behavior with parameters. In these commands, almost all of the functionality is baked in. I have no problem treating this approach as a domain specific language.
What Is a Function?
One thing about the original approach that is problematic is the use of Invoke-Expression
. Generally, we try to avoid this command because if hijacked, can be used to run malicious code. But the real issue is that you can't take the code and run it in a script. The Invoke-Expression
command is creating a function just as if you typed it interactively at the console. This is not that same as creating a function in a script.
A better approach is to create the function item in the Function: PSDrive. A function is nothing more than a named script block.
Set-Item -Path Function:\Get-Lucky -Value {Get-Random -Minimum 1 -Maximum 100}
This adds a new function to the Function: PSDrive. You can see the function by running Get-ChildItem -Path Function:\Get*
. You can run the function by typing Get-Lucky
.
With this concept in mind, I revised the original code.
foreach ($client in $clientFunctions) {
$ClientData = $client -split '='
$ClientName = $ClientData[0]
$ClientId = $ClientData[1]
$cmdText = @"
[CmdletBinding()]
param (
[parameter()][switch]`$Update,
[parameter()][switch]`$UpdateCancel,
[parameter()][switch]`$Detailed
)
Write-Verbose "Starting `$(`$MyInvocation.MyCommand)"
if (`$Update.IsPresent) { Get-CustomerRecords -ClientId $($ClientId) -Action Update }
elseif (`$UpdateCancel.IsPresent) { Get-CustomerRecords -ClientId $($ClientId) -Action Cancel }
elseif (`$Detailed.IsPresent) { Get-CustomerRecords -Client $($ClientId) }
else { Get-CustomerRecords -ClientId $($ClientId) | Select-Object * -ExcludeProperty Client }
"@
$cmd = [scriptblock]::Create($cmdText)
Write-Host "Creating function: $ClientName" -ForegroundColor Green
Set-Content -Path Function:\$ClientName -Value $cmd
}
I'm defining a here string, $cmdText
, that contains the function definition. The client data values will be replaced in the string. You have to be careful about escaping the $
character.
This code creates the same functions, but now I can put this in a script file and dot-source the script. The code uses the client data to define the function.
The function is created in the Function: PSDrive. The function can be run by typing the function name.
Variations on a Theme
Let's explore this further using Active Directory. I have a function that takes a computer name as a parameter and returns custom information. This is the type of command the help desk might run, but I want to make it as easy as possible for them. I'll start by getting a list of computer names from the domain.
$Names = (Get-ADComputer -Filter *).Name
I have a small test domain so I can get everything. Obviously, you can populate a related list however you want. With this list, I can generate a function for each computer name.
foreach ($item in $Names) {
$funName = "Get-$Item"
$cmdText = @"
[CmdletBinding()]
param ()
Write-Verbose "Starting `$(`$MyInvocation.MyCommand)"
`$splat = @{
ComputerName = '$item'
ClassName = 'Win32_OperatingSystem'
Property = 'Caption', 'TotalVisibleMemorySize', 'InstallDate',
'FreePhysicalMemory', 'LastBootUpTime', 'CSName'
ErrorAction = 'Stop'
}
Try {
Write-Verbose "Querying $($item.ToUpper()) for Operating System information"
`$os = Get-CimInstance @splat
[PSCustomObject]@{
PSTypeName = 'OSInfo'
ComputerName = `$os.CSName
OS = `$os.Caption
InstallDate = `$os.InstallDate
Age = (New-TimeSpan -Start `$os.InstallDate -End (Get-Date))
TotalMemory = `$os.TotalVisibleMemorySize
FreeMemory = `$os.FreePhysicalMemory
LastBoot = `$os.LastBootUpTime
Uptime = (New-TimeSpan -Start `$os.LastBootUpTime -End (Get-Date))
}
} #try
Catch {
Throw `$_
} #catch
Write-Verbose "Ending `$($`MyInvocation.MyCommand)"
"@
$cmd = [scriptblock]::Create($cmdText)
Write-Host "Creating function: $FunName" -ForegroundColor Green
#Set-Content -Path Function:\$FunName -Value $cmd
New-Item -Path Function: -Name $FunName -value $cmd -Force
} #foreach