PowerShell in Depth, Second Edition (2015)
Part 3. PowerShell scripting and automation
Chapter 32. Functions that work like cmdlets
This chapter covers
· Defining a problem
· Developing and testing a solution
· Making the solution production-ready
· Adding formatting and type information
We’re not going to introduce any major new concepts or techniques in this chapter. Instead, we’ll use this chapter to bring together many of the things that the previous eight or so chapters covered. We’ll take a task, write a command to perform that task, and then turn that task into a complete script module, complete with error handling, debugging provisions, a custom default view, a type extension, and lots more. This example is intended to be a soup-to-nuts practical illustration of something you might do on your own.
As we walk you through the various steps, pay close attention to the process we use, as well as the final result of that process. When you’re on your own, we’re obviously not going to be there with step-by-step instructions. We’ve tried, in this chapter, to document our way of thinking, and our creation process, so that you can start to adopt them as your own, enabling you to perform this same, build-it-from-scratch process for whatever tasks you need to complete.
We’ll present our solution in progressively more complete steps. When we’ve finished, we’ll provide a formal, numbered listing that has the final product. If you’re eager to just see the end result, skip ahead to listing 32.1.
Additional steps are required to finalize the module, shown in listings 32.2 to 32.4.
32.1. Defining the task
First, you must define the task. For this example, you’ll use a combination of Active Directory and Windows Management Instrumentation (WMI) to write a function that’ll accept an organizational unit (OU) name and return all of the nondisabled computers in that OU as well as any child OUs.
Note
We’re assuming that you’re using the Microsoft Active Directory cmdlets. If you use the Quest cmdlets, the changes are simple. If you can’t use either of these cmdlet sets, it’s possible to code this module using Active Directory Service Interfaces (ADSI), but doing so involves more work.
For each computer, you want to display several pieces of information:
· The date the directory object was created
· The date the computer last changed its password
· The computer’s operating system, service pack, and version
· The computer’s name
· The amount of physical memory installed in the computer
· The number of processors in the computer and the processor architecture (64- or 32-bit)
Most of this information is stored in Active Directory; you’ll need to get the memory and processor information by querying the computer from WMI. It’s possible, of course, that a computer won’t be available for WMI queries when you run your function. If that’s the case, you want the function to output the information it has and to leave the memory and processor fields blank.
You want the objects produced by your function to display in a table by default. You’d like that table to list the following, in order:
· Computer name
· Operating system version
· Installed RAM, in gigabytes with up to two decimal places
· Number of processors
The other information won’t display by default. You also want these objects to have a Ping() method, which will attempt to ping the computer and return either True if it can be pinged or False if it can’t. You want your function to follow all of PowerShell’s normal patterns and practices, including displaying help, examples, and so forth.
When you specify the OU that you want the function to query, you want to be able to do so by giving one or more OU names to a parameter or by piping in strings that contain OU names. You expect to provide OU names in a complete distinguished name (DN) format, such asOU=Sales,OU=East,DC=company,DC=com.
32.2. Building the command
Let’s start by building the command—well, commands, because there will be more than one—that accomplish the task. You’ll do so in a script file so that you can easily edit and rerun the commands over and over until you get it right.
Tip
In terms of process, this is an important step: You shouldn’t start building the structure of a fancy function, adding error handling and all that other jazz, until you’ve gotten the basics working properly.
Here’s your first attempt:
Import-Module ActiveDirectory
$computers = Get-ADComputer -Filter * -SearchBase "dc=company,dc=pri"
$computers
Note that you didn’t even attempt to add the WMI piece yet. One thing at a time—that’s the way to get something done without frustrating yourself. If you approach the problem one step at a time and prove the code works, you limit the troubleshooting and debugging you need to do because any problems will almost always be in the last bit of code you added.
You’re storing the retrieved computers in the $computers variable and then simply displaying its contents to check your work. Try running this for yourself, changing the distinguished name of the -SearchBase parameter to match your domain, and you’ll notice that your output has a problem:
DistinguishedName : CN=WIN-KNBA0R0TM23,OU=Domain
Controllers,DC=company,DC=pri
DNSHostName : WIN-KNBA0R0TM23.company.pri
Enabled : True
Name : WIN-KNBA0R0TM23
ObjectClass : computer
ObjectGUID : 274d4d87-8b63-4279-8a81-c5dd5963c4a0
SamAccountName : WIN-KNBA0R0TM23$
SID : S-1-5-21-29812541-3325070801-1520984716-1000
UserPrincipalName :
Doh! You don’t have most of the properties you wanted, because you didn’t ask the directory service to give them to you. Again, this is why you started small. If you’d simply taken that output and charged ahead with the rest of the script, you’d be spending a good amount of time debugging. As it is, you can immediately check your work, spot your error, and go back and fix it. After a few tries, this is what you come up with:
Import-Module ActiveDirectory
$computers = Get-ADComputer -Filter * -SearchBase "dc=company,dc=pri" `
-Properties Name,OperatingSystem,OperatingSystemVersion,
OperatingSystemServicePack,passwordLastSet,
whenCreated
foreach ($computer in $computers) {
$cs = Get-WmiObject -Class Win32_ComputerSystem `
-ComputerName $computer.Name
$properties = @{'ComputerName'=$computer.name
'OS'=$computer.OperatingSystem
'OSVersion'=$computer.OperatingSystemVersion
'SPVersion'=$computer.OperatingSystemServicePack
'WhenCreated'=$computer.whenCreated
'PasswordLastSet'=$computer.passwordLastSet
'Processors'=$cs.NumberOfProcessors
'RAM'='{0:N}' -f ($cs.TotalPhysicalMemory / 1GB)
}
$object = New-Object -TypeName PSObject -Property $properties
Write-Output $object
}
You can see that you’ve added the WMI query in the ForEach loop and that the final object being written to the pipeline has all the information you want:
ComputerName : WIN-KNBA0R0TM23
WhenCreated : 8/30/2011 1:26:17 PM
RAM : 1.00
PasswordLastSet : 10/10/2014 3:37:18 PM
OSVersion : 6.1 (7601)
OS : Windows Server 2008 R2 Standard
SPVersion : Service Pack 1
Processors : 1
(The format of the time and date information is controlled by the settings on our systems, which means that you may see a different format.) At this point, you have your basic functionality working properly. That’s the hardest part of a task like this, so you’ve focused on getting it right, rather than on the structure of a function, or the addition of error handling, or anything else. You’ll notice that you’ve hardcoded the search base, scoping it to the entire domain, and for now you’re assuming that every computer will be available for the WMI query.
32.3. Parameterizing the pipeline
Our next step will be to find all of the hardcoded information and parameterize it. You’ve already said that the only input you want to provide is the OU to query, which is the search base, so you’ll create a –SearchBase parameter. You’ll add the necessary decorators to enable cmdlet-style parameter binding, and you’ll make your parameter mandatory. You’ll also rig it up to accept pipeline input ByValue so that you can pipe strings into it. We’ll highlight the additions and changes in boldface:
[CmdletBinding()]
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[String[]]$searchBase
)
BEGIN {
Import-Module ActiveDirectory
}
PROCESS {
foreach ($ou in $searchBase) {
$computers = Get-ADComputer -Filter * -SearchBase $ou `
-Properties Name,OperatingSystem,
OperatingSystemVersion,
OperatingSystemServicePack,
passwordLastSet,whenCreated
foreach ($computer in $computers) {
$cs = Get-WmiObject -Class Win32_ComputerSystem `
-ComputerName $computer.name
$properties = @{'ComputerName'=$computer.name;
'OS'=$computer.OperatingSystem;
'OSVersion'=$computer.OperatingSystemVersion;
'SPVersion'=$computer.OperatingSystemServicePack;
'WhenCreated'=$computer.whenCreated;
'PasswordLastSet'=$computer.passwordLastSet;
'Processors'=$cs.NumberOfProcessors;
'RAM'='{0:N}' -f ($cs.TotalPhysicalMemory / 1GB)
}
$object = New-Object -TypeName PSObject -Property $properties
Write-Output $object
} #end computer foreach
} #end OU foreach
}
END {}
You also adjusted the formatting a bit so that every nested block is properly indented. (We had to reformat slightly just to make everything fit neatly on the page of this book.) But you didn’t add any functionality. Let’s review what you did:
You started the script with the [CmdletBinding()] decorator . Doing so allowed you to add information to your parameter. It also supplies access to the common parameters such as –Verbose and –Debug. The parameter itself is declared within a parameter block . You used the[Parameter()] decorator (which is legal because you used cmdlet binding) to declare your parameter as mandatory and to indicate that it’ll accept input from the pipeline ByValue. The parameter itself is declared as accepting one or more strings, so those strings will be passed along from the pipeline.
Because the ActiveDirectory module needs to be loaded only once, you added a BEGIN block to do that at the start of the execution . The PROCESS block contains the working commands for your script . It’ll execute once for every object input from the pipeline.
Because you’ve set this up to accept one or more search OUs, you need to go through those one at a time, which is what the foreach block does . In it, you’ll take one path at a time out of $searchBase and put it in $ou so that you can work with it. You therefore place $ou in the command , instead of your hardcoded search path.
Just for neatness, you included an END block . There’s nothing in it, so you could just omit it, but it feels right to add it because you also used BEGIN and PROCESS.
Tip
Notice that the closing curly braces for the two foreach loops are labeled with comments. This is a useful technique in long pieces of code so that you’re aware of where your loops end. It also helps keep track of the braces because it’s easy to leave one out and it can take a while to track down even if you’re using the ISE.
It’s important to test the script again after making all those changes; a simple typo could mess you up, and it’ll be easier to catch it now than later. You’ll save the script as Test.ps1 and then test it in four ways:
# Make sure you can use a single location with a parameter
./test –searchBase 'dc=company,dc= pri '
# Now multiple values with a parameter
./test –searchBase 'dc=company,dc= pri ','dc=company,dc= pri '
# pipe in one value
'dc=company,dc= pri ' | ./test
# pipe in multiple values
'dc=company,dc= pri ', 'dc=company,dc= pri ' | ./test
Notice that you’re not trying to be ambitious with your testing. You’re still in a test domain (running inside a virtual machine), and it has only one computer in the domain. But the commands work properly: The first and third return one computer, and the second and last return two computers (technically the same one, queried twice).
Tip
Put your tests into a script and then you can call a script to test your script! Your tests then become repeatable and are easy to reuse when you change the script. If you want to impress your developer friends, the technique is known as regression testing.
If you run this script without supplying the distinguished name of at least one OU, you’ll be prompted for values (this is because of the Mandatory=$true statement on the [Parameter()] decorator). The script is set to accept an array, so it’ll keep prompting for multiple inputs. Press Enter in the console or click OK at the ISE prompt to tell PowerShell there’s no more input.
32.4. Adding professional features
Now you can start adding some professionalism to the script. These steps aren’t required, but the more you can add to your script, especially so that it acts like a cmdlet, the better your script. You want to make your script as robust as possible as well as easy to troubleshoot or debug.
32.5. Error handling
You’ve said that you want the memory and processor columns to be blank if you can’t query a computer via WMI, so let’s do that next:
Here’s what you added:
· You enclosed the WMI query in a Try block .
· In the Try block, you first set a tracking variable equal to $True . You’ll use this to keep track of whether the WMI query succeeded.
· You have to tell the Get-WmiObject cmdlet to alter its normal error behavior , which is done with the –ErrorAction parameter. Setting the parameter to Stop will ensure that you get to catch any errors that occur.
· That catching occurs in the Catch block , where you’re setting your tracking variable to $False, indicating that the WMI query failed.
· You also added an If construct , which will check the contents of the tracking variable. If it’s $True, then the WMI query succeeded, and you’ll append the WMI information to your hash table. If it’s $False, then the WMI query failed. You’ll still append the two columns to the hash table, but you’ll put blank values in them.
Once again, you’ve made sure to test your script, with both working and nonworking computer names. You added a computer object to the directory and made sure it doesn’t exist on the network so that you have a bad computer name to query. Here’s the test:
PS C:\> .\test.ps1 -searchBase "dc=company,dc=pri"
PasswordLastSet : 10/10/2014 3:37:18 PM
WhenCreated : 8/30/2011 1:26:17 PM
RAM : 1.00
OSVersion : 6.1 (7601)
OS : Windows Server 2008 R2 Standard
Processors : 1
SPVersion : Service Pack 1
ComputerName : WIN-KNBA0R0TM23
PasswordLastSet : 12/1/2011 10:31:46 AM
WhenCreated : 12/1/2011 10:31:45 AM
RAM :
OSVersion :
OS :
Processors :
SPVersion :
ComputerName : BAD
As you can see, Active Directory returns blank properties for information it doesn’t have, such as OSVersion, OS, and SPVersion.
Note
It’s also possible for someone to specify a search base that doesn’t exist, which would return an error. You’re choosing not to deal with that because the normal PowerShell error message for that situation is pretty descriptive. By not attempting to trap the normal error, you’re allowing it to be displayed to the user, who will see it and hopefully know what they’ve done wrong.
You might also notice at this point that the output from your script doesn’t list the properties in the same order in which you defined them. That’s okay—PowerShell kind of puts things in whatever order it wants. You can always fix that by piping the output to Select-Object or a Formatcmdlet:
PS C:\> .\32.ps1 -searchBase "dc=company,dc=pri" |
Format-Table ComputerName,WhenCreated,OSVersion,RAM,Processors,
SPVersion
ComputerName WhenCreated OSVersion RAM Processors SPVersion
------------ ----------- --------- --- ---------- ---------
WIN-KNBA0... 8/30/2011... 6.1 (7601) 1.00 1 Service ...
BAD 12/1/2011...
That’s the beauty of producing objects as output: You can use all of PowerShell’s other capabilities to get the output into whatever form you need it on any given day or for any situation. Alternatively, you can use an ordered hash table for the properties (see chapter 16), which will preserve the order you want.
32.5.1. Adding verbose and debug output
While you’re at it, you should go ahead and add some verbose and debug output. Right now the script is working fine, but you’re not so confident that you think you’ll never have to debug it! Adding some debug information right now will save time when the inevitable bugs creep in later. The verbose output will serve to document what the script is doing, as well as provide progress information to someone who’s nervous about whether the script is doing anything:
[CmdletBinding()]
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[String[]]$searchBase
)
BEGIN {
Write-Verbose "Loading ActiveDirectory module"
Import-Module ActiveDirectory
}
PROCESS {
Write-Debug "Starting PROCESS block"
foreach ($ou in $searchBase) {
Write-Verbose "Getting computers from $ou"
$computers = Get-ADComputer -Filter * -SearchBase $ou `
-Properties Name,OperatingSystem,
OperatingSystemVersion,
OperatingSystemServicePack,
passwordLastSet,whenCreated
Write-Verbose "Got $($computers | measure | select -expand count)"
foreach ($computer in $computers) {
Try {
Write-Verbose "WMI query to $computer"
$wmi_worked = $true
$cs = Get-WmiObject -Class Win32_ComputerSystem `
-ComputerName $computer.name -ErrorAction Stop
} Catch {
Write-Verbose "WMI query failed"
$wmi_worked = $false
}
Write-Debug "Assembling property hash table"
$properties = @{'ComputerName'=$computer.name;
'OS'=$computer.OperatingSystem;
'OSVersion'=$computer.OperatingSystemVersion;
'SPVersion'=$computer.OperatingSystemServicePack;
'WhenCreated'=$computer.whenCreated;
'PasswordLastSet'=$computer.passwordLastSet
}
if ($wmi_worked) {
$properties += @{'Processors'=$cs.NumberOfProcessors;
'RAM'='{0:N}' -f ($cs.TotalPhysicalMemory / 1GB)
}
}
else {
$properties += @{'Processors'='';
'RAM'=''}
}
Write-Debug "Property hash table complete"
$object = New-Object -TypeName PSObject -Property $properties
Write-Output $object
} #end computer foreach
} # end OU foreach
}
END {}
You followed a couple of rules when adding the new output:
· Because you don’t have a specific bug you’re trying to track down yet, you added Write-Debug commands at key points in the script: as the main PROCESS block begins and before and after you create the hash table with your output information. If you need to debug, you expect to have to start with those sections.
· You added Write-Verbose calls before any major delays might occur, such as making a WMI query, and before each major section of the script.
Running your script again with the –verbose parameter turns on verbose output. It might surprise you:
PS C:\> .\test.ps1 -searchBase "dc=company,dc=pri" -verbose
VERBOSE: Loading ActiveDirectory module
VERBOSE: Importing cmdlet 'Add-ADComputerServiceAccount'.
VERBOSE: Importing cmdlet
'Add-ADDomainControllerPasswordReplicationPolicy'.
...
VERBOSE: Importing cmdlet 'Unlock-ADAccount'.
VERBOSE: Getting computers from dc=company,dc=pri
VERBOSE: Got 2
VERBOSE: WMI query to CN=WIN-KNBA0R0TM23,OU=Domain
Controllers,DC=company,DC=pri
PasswordLastSet : 10/10/2014 3:37:18 PM
WhenCreated : 8/30/2011 1:26:17 PM
RAM : 1.00
OSVersion : 6.1 (7601)
OS : Windows Server 2008 R2 Standard
Processors : 1
SPVersion : Service Pack 1
ComputerName : WIN-KNBA0R0TM23
VERBOSE: WMI query to CN=BAD,CN=Computers,DC=company,DC=pri
VERBOSE: WMI query failed
PasswordLastSet : 12/1/2011 10:31:46 AM
WhenCreated : 12/1/2011 10:31:45 AM
RAM :
OSVersion :
OS :
Processors :
SPVersion :
ComputerName : BAD
We clipped some of the output in the middle of all that to save space—you can see the “...” we inserted. What happened? Well, when you added the –Verbose switch, it passed that along to the other cmdlets in the script, including Import-Module ActiveDirectory. So your verbose output included all the commands that the module was loading. But you can see the output you added yourself! You can tell Import-Module to suppress its own verbose output:
Import-Module ActiveDirectory -Verbose:$false
Test your script again, with verbose output turned on:
PS C:\> .\test.ps1 -searchBase "dc=company,dc=pri" -verbose
VERBOSE: Loading ActiveDirectory module
VERBOSE: Getting computers from dc=company,dc=pri
VERBOSE: Got 2
VERBOSE: WMI query to CN=WIN-KNBA0R0TM23,OU=Domain
Controllers,DC=company,DC=pri
PasswordLastSet : 10/10/2014 3:37:18 PM
WhenCreated : 8/30/2011 1:26:17 PM
RAM : 1.00
OSVersion : 6.1 (7601)
OS : Windows Server 2008 R2 Standard
Processors : 1
SPVersion : Service Pack 1
ComputerName : WIN-KNBA0R0TM23
VERBOSE: WMI query to CN=BAD,CN=Computers,DC=company,DC=pri
VERBOSE: WMI query failed
PasswordLastSet : 12/1/2011 10:31:46 AM
WhenCreated : 12/1/2011 10:31:45 AM
RAM :
OSVersion :
OS :
Processors :
SPVersion :
ComputerName : BAD
Much better!
32.5.2. Defining a custom object name
You know that you’re going to want to create a default view, and a type extension, for your output. To do that, you need to ensure your output object has a unique type name. It takes only one line of code to add it, just after the New-Object command and before the Write-Outputcommand:
$object = New-Object -TypeName PSObject -Property $properties
$object.PSObject.TypeNames.Insert(0,'Company.ComputerInfo')
Write-Output $object
Doing this now will set you up for some of your next tasks.
32.6. Making it a function and adding help
You said that you wanted your script to follow PowerShell’s best practices and patterns, and displaying help is one of them. Listing 32.1 shows the script again, with the help added.
Note
Some folks refer to this type of function as a script cmdlet because it looks, feels, and works almost exactly like a real PowerShell cmdlet but was created in a script instead of in Visual Studio.
Listing 32.1. Toolkit.psm1
function Get-COComputerInfo {
<#
.SYNOPSIS
Retrieves key computer information from AD and WMI
.DESCRIPTION
Get-COComputerInfo retrieves key computer information from both
Active Directory (AD), and from the computer itself using WMI. In
the case of a computer that is in AD but not available for the WMI
query, certain information in the output may be blank.
You need to specify a search path, and can specify more than one.
This can be an organizational unit (OU), or an entire domain.
The command will recurse all sub-OUs within whatever path(s) you
specify.
.PARAMETER searchBase
A string, or multiple strings, of locations to start looking for
computer objects in AD. Provide this in DN format, such as:
'dc=company,dc=com','ou=sales,dc=company,dc=com'
.EXAMPLE
This example searches the Sales OU, and all sub-OUs:
Get-COComputerInfo -searchBase 'ou=Sales,dc=company,dc=com'
.EXAMPLE
This example reads OU DNs from a text file, and searches them:
Get-Content paths.txt | Get-COComputerInfo
#>
[CmdletBinding()]
param(
[Parameter(Mandatory=$true,ValueFromPipeline=$true)]
[String[]]$searchBase
)
BEGIN {
Write-Verbose "Loading ActiveDirectory module"
Import-Module ActiveDirectory -Verbose:$false
}
PROCESS {
Write-Debug "Starting PROCESS block"
foreach ($ou in $searchBase) {
Write-Verbose "Getting computers from $ou"
$computers = Get-ADComputer -Filter * -SearchBase $ou `
-Properties Name,OperatingSystem,
OperatingSystemVersion,
OperatingSystemServicePack,
passwordLastSet,whenCreated
Write-Verbose "Got $($computers | measure | select -expand count)"
foreach ($computer in $computers) {
Try {
Write-Verbose "WMI query to $computer"
$wmi_worked = $true
$cs = Get-WmiObject -Class Win32_ComputerSystem `
-ComputerName $computer.name -ErrorAction Stop
} Catch {
Write-Verbose "WMI query failed"
$wmi_worked = $false
}
Write-Debug "Assembling property hash table"
$properties = @{'ComputerName'=$computer.name;
'OS'=$computer.OperatingSystem;
'OSVersion'=$computer.OperatingSystemVersion;
'SPVersion'=$computer.OperatingSystemServicePack;
'WhenCreated'=$computer.whenCreated;
'PasswordLastSet'=$computer.passwordLastSet
}
if ($wmi_worked) {
$properties += @{'Processors'=$cs.NumberOfProcessors;
'RAM'='{0:N}' -f ($cs.TotalPhysicalMemory / 1GB)}
} else {
$properties += @{'Processors'='';
'RAM'=''}
}
Write-Debug "Property hash table complete"
$object = New-Object -TypeName PSObject -Property $properties
$object.PSObject.TypeNames.Insert(0,'Company.ComputerInfo')
Write-Output $object
} #end computer foreach
} #end OU foreach
}
END {}
}
You wrapped the contents of the original PowerShell code in a function, named Get-COComputerInfo, so that it has a cmdlet-like name, and you added the CO prefix to the noun. The pretend organization is just named company, so you’re prefixing all of its script, cmdlet, and function names with CO. Wrapping the script in a function makes it easier to add other functions to the same file, enabling you to build a little library of utilities for yourself.
So that’s it. You could now load this script into memory and run Help Get-COComputerInfo to display help.
But loading this into memory isn’t necessarily easy, because you’ve encapsulated the code into a function. You can’t just run the script and then start using the function. This is probably a good time to make the script into a script module, simply by saving it in a new location and with a new filename. You’ll save it in your Documents folder:
[My ]Documents\WindowsPowerShell\Modules\Toolkit\Toolkit.psm1
Note
The filename is important for this file. You’re naming the module Toolkit, and so both the containing folder and the script file have to use that as their filename: Toolkit for the folder and Toolkit.psm1 for the filename. Anything else, and this won’t work properly.
Now you can load the script into memory by running Import-Module Toolkit, and then either run Get-COComputerInfo or run Help Get-COComputerInfo. You can remove the module by running Remove-Module Toolkit; that’d be necessary if you made any changes to the script and wanted to reload it.
You’ve also added help capability to the function. You could put the help at the end of the function if you prefer it to be tucked away. See about_Comment_Based_Help for more details.
32.7. Creating a custom view
We’ve already given you a whole chapter (26) on custom views, so please refer back to that for a more detailed breakdown of how to create them. In this chapter, you’re just going to create a view to go along with your Toolkit.psm1 script module, specifically to create a default view for yourCompany.ComputerInfo object, which is produced by the Get-COComputerInfo function.
Save the view file, shown in the following listing, here:
[My ]Documents\WindowsPoyourShell\Modules\Toolkit\Toolkit.format.ps1xml
Listing 32.2. Toolkit.format.ps1xml
<?xml version="1.0" encoding="utf-8" ?>
<Configuration>
<ViewDefinitions>
<View>
<Name>Company.ComputerInfo</Name>
<ViewSelectedBy>
<TypeName>Company.ComputerInfo</TypeName>
</ViewSelectedBy>
<TableControl>
<TableHeaders>
<TableColumnHeader>
<Label>ComputerName</Label>
<Width>14</Width>
</TableColumnHeader>
<TableColumnHeader>
<Label>OSVersion</Label>
</TableColumnHeader>
<TableColumnHeader>
<Label>RAM</Label>
</TableColumnHeader>
<TableColumnHeader>
<Label>Procs</Label>
</TableColumnHeader>
</TableHeaders>
<TableRowEntries>
<TableRowEntry>
<TableColumnItems>
<TableColumnItem>
<PropertyName>ComputerName</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>OSVersion</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>RAM</PropertyName>
</TableColumnItem>
<TableColumnItem>
<PropertyName>Processors</PropertyName>
</TableColumnItem>
</TableColumnItems>
</TableRowEntry>
</TableRowEntries>
</TableControl>
</View>
</ViewDefinitions>
</Configuration>
You should test this, of course. Load the formatting information into memory and then run your command:
PS C:\> Update-FormatData -PrependPath C:\Users\Administrator\Documents\
WindowsPowerShell\Modules\Toolkit\toolkit.format.ps1xml
PS C:\> Get-COComputerInfo -searchBase 'dc=company,dc=pri'
ComputerName OSVersion RAM Procs
------------ --------- --- -----
WIN-KNBA0R0... 6.1 (7601) 1.00 1
BAD
Looking good! You’ll want that formatting information to load automatically, along with the module, but making that happen is the last step you’ll take.
32.8. Creating a type extension
Reviewing the goals for this script, you also wanted the output objects to have a Ping() method that returned True or False. We covered type extensions in chapter 27, so we’ll just jump right in and give you this one (listing 32.3). Save it as
[My ]Documents\WindowsPowerShell\Modules\Toolkit\toolkit.ps1xml
Note
You’ve used toolkit as the filename for your PSM1 file, this .ps1xml file, and the .format.ps1xml file that you created. You didn’t need to be that consistent; PowerShell doesn’t care. This file could’ve been named Fred.ps1xml, and everything would still work the same. But keeping consistent names does make it easier for you to keep track of everything in your head.
Listing 32.3. Toolkit.ps1xml
<?xml version="1.0" encoding="utf-8" ?>
<Types>
<Type>
<Name>Company.ComputerInfo</Name>
<Members>
<ScriptMethod>
<Name>Ping</Name>
<Script>
Test-Connection -computername $this.ComputerName -quiet
</Script>
</ScriptMethod>
</Members>
</Type>
</Types>
You’ll load the file in listing 32.3 into memory and then give it a brief test:
PS C:\> Update-TypeData -PrependPath C:\Users\Administrator\Documents\
WindowsPowerShell\Modules\Toolkit\toolkit.ps1xml
PS C:\ > Get-COComputerInfo -searchBase 'dc=company,dc=pri' |
foreach-object { $_.ping() }
True
False
Okay—not an awesome test, but good enough to prove that your new Ping() method is working correctly. If you want to see the results from multiple computers, you need to be able to link the ping result to the computer name:
Get-COComputerInfo -searchBase 'dc=company,dc=pri' |
select ComputerName, @{N="Pingable"; E={$($_.Ping())}}
Excellent!
32.9. Making a module manifest
Right now, your Toolkit module consists of three parts:
· The script file that contains your function, which is Toolkit.psm1, shown in listing 32.1
· The view file, which is Toolkit.format.ps1xml, shown in listing 32.2
· The type extension, which is Toolkit.ps1xml, shown in listing 32.3
Ideally, you want all three of these files to load and unload as a unit when you run Import-Module or Remove-Module. The way to do that is a module manifest, which you’ll call Toolkit.psd1. Now, the filename does matter with this one: Because the module is contained in a folder named toolkit, the manifest filename also has to be toolkit, or PowerShell won’t be able to find it.
You’ll run the New-ModuleManifest command to create the manifest file. Rather than trying to remember all of its parameters, you’ll just let it prompt you for what it wants:
PS C:\Users\Administrator\Documents\WindowsPowerShell\Modules\Toolkit> New-
ModuleManifest
cmdlet New-ModuleManifest at command pipeline position 1
Supply values for the following parameters:
Path: toolkit.psd1
NestedModules[0]:
Author: Don, Jeffery, and Richard
CompanyName: PowerShell in Depth
Copyright: Public Domain!
ModuleToProcess: toolkit.psm1
Description: Corporate PowerShell Tools
TypesToProcess[0]: toolkit.ps1xml
TypesToProcess[1]:
FormatsToProcess[0]: toolkit.format.ps1xml
FormatsToProcess[1]:
RequiredAssemblies[0]:
FileList[0]: toolkit.psm1
FileList[1]: toolkit.ps1xml
FileList[2]: toolkit.format.ps1xml
FileList[3]:
PS C:\Users\Administrator\Documents\WindowsPowerShell\Modules\Toolkit>
The cmdlet will create the module manifest, included in the next listing so that you can follow along.
Listing 32.4. Toolkit.psd1
#
# Module manifest for module 'toolkit'
#
# Generated by: Don, Jeffery, and Richard
#
# Generated on: 12/1/2013
#
@{
# Script module or binary module file associated with this manifest
ModuleToProcess = 'toolkit.psm1'
# Version number of this module.
ModuleVersion = '1.0'
# ID used to uniquely identify this module
GUID = '53901d6b-a07b-4c38-90f3-278737bc910c'
# Author of this module
Author = 'Don, Jeffery, and Richard'
# Company or vendor of this module
CompanyName = 'PowerShell in Depth'
# Copyright statement for this module
Copyright = 'Public Domain!'
# Description of the functionality provided by this module
Description = 'Corporate PowerShell Tools'
# Minimum version of the Windows PowerShell engine required by this module
PowerShellVersion = ''
# Name of the Windows PowerShell host required by this module
PowerShellHostName = ''
# Minimum version of the Windows PowerShell host required by this module
PowerShellHostVersion = ''
# Minimum version of the .NET Framework required by this module
DotNetFrameworkVersion = ''
# Minimum version of the common language runtime (CLR) required by
this module
CLRVersion = ''
# Processor architecture (None, X86, Amd64, IA64) required by this module
ProcessorArchitecture = ''
# Modules that must be imported into the global environment prior to
[CA}importing this module
RequiredModules = @()
# Assemblies that must be loaded prior to importing this module
RequiredAssemblies = @()
# Script files (.ps1) that are run in the caller's environment prior
to importing this module
ScriptsToProcess = @()
# Type files (.ps1xml) to be loaded when importing this module
TypesToProcess = 'toolkit.ps1xml'
# Format files (.ps1xml) to be loaded when importing this module
FormatsToProcess = 'toolkit.format.ps1xml'
# Modules to import as nested modules of the module specified
in ModuleToProcess
NestedModules = @()
# Functions to export from this module
FunctionsToExport = '*'
# Cmdlets to export from this module
CmdletsToExport = '*'
# Variables to export from this module
VariablesToExport = '*'
# Aliases to export from this module
AliasesToExport = '*'
# List of all modules packaged with this module
ModuleList = @()
# List of all files packaged with this module
FileList = 'toolkit.psm1', 'toolkit.ps1xml', 'toolkit.format.ps1xml'
# Private data to pass to the module specified in ModuleToProcess
PrivateData = ''
}
And with that, you’ve finished. Opening a brand-new shell and running Import-Module Toolkit successfully loads your Get-COComputerInfo command, its default view, and its type extension (which creates the Ping() method on your output objects). Congratulations!
32.10. Summary
This chapter has provided a complete, from-scratch look at building a production-quality tool. You added error handling and verbose output, ensured that help was available, and packaged the command as a module that includes a custom default view and a useful type extension. This is what you should aspire to for your PowerShell commands: Make them look, work, and feel just like a native PowerShell command as much as possible. As you’ve seen, there’s a good amount of work involved, but most of it’s straightforward. And the payoff is an easier-to-use, more consistent command that works perfectly within the shell.