Windows PowerShellBuilding a Better Inventory Tool

Don Jones


Objects Are Always the Same
Flexible Output
Flexible Input
What about Errors?
Making This Really Useful

In the previous installment of this column, I created a function that could retrieve service pack inventory information from multiple remote computers. While this is a very practical tool that can be quite handy, I was also concerned with the process I used to develop the tool. In this installment, I want to demonstrate the process I use so you will be better equipped to build your own functions.

The function I created in the previous installment looks like this:

Function Get-SPInventory { PROCESS { $wmi = Get-WmiObject Win32_OperatingSystem –comp $_ | Select CSName,BuildNumber, ServicePackMajorVersion Write-Output $wmi } }

And here is how you can use it:

Get-Content c:\computernames.txt | Get-SPInventory

This works well, if the text file includes one computer name per line.

One particular weakness of this function is that it is limited to returning data from a single WMI (Windows Management Instrumentation) class. So what if I also want to return the BIOS serial number?

Video (no longer available)

Following up on the previous installment of his Windows PowerShell column, Don Jones shows you how to make an even better inventorying tool. And, in doing so, he demonstrates a sound process for building your own custom Windows PowerShell functions.

Objects Are Always the Same

The problem is that my function is retrieving the Win32_OperatingSystem class from WMI and simply outputting that object. The Win32_OperatingSystem class, like all objects, is fixed in the data it contains—it doesn't include a BIOS serial number and never can. As long as I am merely outputting Win32_OperatingSystem objects, my output can never include a serial number.

No matter how much you rummage around in WMI, you will not find a single object class that includes both BIOS serial numbers and service pack information. As a result, my function cannot simply output a WMI class. Instead, I need it to output a custom object that I make up on the fly—one that contains all of the data I need.

To create a new, blank object with no properties, I just run this:

$obj = New-Object PSObject

The new object is stored in the variable $obj, and I can add whatever data I want to it. Once I have added all my data, it will then become the output of my function.

Inside my function, I've retrieved the Win32_OperatingSystem information and stored it in the variable $wmi. Using that information is as simple as referring to properties of $wmi. For example, to get the BuildNumber property, I would use this:


To add a property to my custom object, I have to pipe the object (which is in the variable $obj) to Add-Member. I tell Add-Member what type of property I want to add (always a NoteProperty), the name of the property I want to add, and the value I want the property to have, like so:

$obj | Add-Member NoteProperty BuildNumber ($wmi.BuildNumber)

I can do this for the computer system name and service pack version as well:

$obj | Add-Member NotePropertyCSName ($wmi.CSName) $obj | Add-Member NotePropertySPVersion ($wmi.ServicePackMajorVersion)

Wrapping that up yields a new function (see Figure 1). Notice that I've changed the Write-Output line to output the custom object, rather than the original $wmi object.

Figure 1 A custom object

Function Get-SPInventory { PROCESS { $wmi = Get-WmiObject Win32_ OperatingSystem –comp $_ | Select CSName,BuildNumber,ServicePack MajorVersion $obj = New-Object PSObject $obj | Add-Member NoteProperty BuildNumber ($wmi.BuildNumber) $obj | Add-Member NoteProperty CSName ($wmi.CSName) $obj | Add-Member NoteProperty SPVersion ($wmi.ServicePackMajorVersion) Write-Output $obj } }

Now I need to get that BIOS serial number. Some searching turns up the Win32_BIOS class, which has a Serial­Number property (Figure 2 shows a portion of the online documentation page). So I simply have to query the Win32_BIOS class, add the Serial­Number property to my custom object, and I'm done. You can see the revised function in Figure 3.


Figure 2 Win32_BIOS class documentation page (Click the image for a larger view)

Figure 3 With SerialNumber property

Function Get-SPInventory { PROCESS { $wmi = Get-WmiObject Win32_ OperatingSystem –comp $_ | Select CSName,BuildNumber,ServicePack MajorVersion $obj = New-Object PSObject $obj | Add-Member NoteProperty BuildNumber ($wmi.BuildNumber) $obj | Add-Member NoteProperty CSName ($wmi.CSName) $obj | Add-Member NoteProperty SPVersion ($wmi.ServicePackMajorVersion) $wmi = Get-WmiObject Win32_BIOS –comp $_ | Select SerialNumber $obj | Add-Member NoteProperty BIOSSerial ($wmi.SerialNumber) Write-Output $obj } }

Flexible Output

By using a custom object for the output, I've created a variety of potential uses for this function. For example, to include only Windows Server 2008 computers, I can use this:

Gc c:\computernames.txt | Get-SPInventory | Where { $_.BuildNumber –eq 6001 }

Or, to create an HTML file listing all of the Windows Vista computers that don't have Service Pack 1 installed, I can use this:

Gc c:\computernames.txt | Get-SPInventory | Where { $_.BuildNumber –eq 6000 } | ConvertTo-HTML | Out-File c:\VistaInventory.html

The possibilities are endless—XML, CSV, tables, lists, HTML, sorted, filtered, grouped, and so on. The built-in cmdlets in Windows PowerShell are designed to work with objects. By creating objects for the output, you can take advantage of everything Windows PowerShell is capable of doing—with no extra work!

Flexible Input

Unfortunately, my function isn't as flexible in terms of input. This is partially a limitation of how functions are designed in version 1 of Windows PowerShell—version 2 will introduce script cmdlets, which offer a great deal more flexibility.

My function expects its pipeline input to be simple string objects. The function wouldn't work if I were to replace the Get-Content command with, say, a cmdlet to retrieve all the computer names from Active Directory, like this:

Get-QADComputer | Get-SPInventory

In this case, the Get-QADComputer command (part of a free set of Active Directory management cmdlets you can get at is returning an object that has a Name property, rather than returning simple string objects. To make that command work, I would have to tweak the function, shown in Figure 4; it's important for you to note that the changes are highlighted in red.

Figure 4 The final product

Function Get-SPInventory { PROCESS { $wmi = Get-WmiObject Win32_OperatingSystem –comp $_.Name | Select CSName,BuildNumber, ServicePackMajorVersion $obj = New-Object PSObject $obj | Add-Member NoteProperty BuildNumber ($wmi.BuildNumber) $obj | Add-Member NoteProperty CSName ($wmi.CSName) $obj | Add-Member NoteProperty SPVersion ($wmi.ServicePackMajorVersion) $wmi = Get-WmiObject Win32_BIOS –comp $_.Name | Select SerialNumber $obj | Add-Member NoteProperty BIOSSerial ($wmi.SerialNumber) Write-Output $obj } }

Rather than passing the entire pipeline object to the –computerName parameter, my function is now acting on the pipeline object's Name property. This minor change is quite worthwhile in terms of flexibility—by using Get-QADComputer, I am able to limit my input to computers in a specific organizational unit, for example, or to only those computers that are matching other criteria based on Active Directory attributes.

What about Errors?

Inevitably, this function will eventually come across a computer that it can't connect to, it doesn't have permission to connect to, or so on. The function, as it is currently written, will display an error message in red text within the Windows PowerShell console window, and then continue on to the next computer.

That, of course, might be just what you want it to do. Or you might prefer the function to suppress those error messages. This is easy to do simply by adding this one line to the start of the function's PROCESS script block:

$ErrorActionPreference = "SilentlyContinue"

Then again, you might want to take a more complex approach and create an error log that lists the names of the computers that weren't reachable. Windows PowerShell can do that, but you'll have to wait until next month to see how to do this!

Cmdlet of the Month: Export-Alias And Import-Alias

Here's a great way to share custom aliases that you've created or to easily load your custom aliases each time the shell starts. After you create all the aliases you want, export them to a file, like this:

Export-Alias c:\aliases.xml

Then, to load those aliases back into the shell, run this command:

Import-Alias c:\aliases.xml

You can place that second command in your Windows PowerShell profile script to have it run each time the shell starts. Also, you can place the file on a network share in order to have it readily available to the other administrators you work with.

Making This Really Useful

So far, you may have been typing this function into a .ps1 file and running the script. That's all well and good, but it does have a few usability problems. One issue is that anytime you want to use the function you would have to open up that script file, modify the line that calls the function (to add whatever output, sorting, or other commands you need at that time), save the script, and run it. It would be much easier if the function itself were available right within the shell's console window, almost like a cmdlet.

There are two options for going about doing this. The first is to simply copy the function into a Windows PowerShell profile script, one of the four script files that the shell will automatically run (if they exist) each time it starts. The Quick Start Guide that is installed with Windows PowerShell lists the four locations.

I usually use the file that is named profile.ps1, which has to be located in the folder named Windows­PowerShell (no spaces) inside your Documents folder (or My Documents on Windows XP and Windows Server 2003 computers). Once the function is in your profile, it'll be available right from the command prompt anytime you're in the shell.

The second option is to include the function—just the function, with no other code—in a script and dot source that script into the shell. What typically happens when you run a script is Windows PowerShell creates a new scope for the script. Anything that goes on in the script takes place within that scope, such as the definition of functions. When the script finishes running, the scope is discarded and anything that went on within that scope is lost.

As a result of this, remember that if you have a script file that contains only the Get-SP­Inventory function, running that script will create a new scope, define the function, and discard the scope, and then the function will go away. Obviously, that's not very useful to you.

Dot sourcing, on the other hand, runs a script without creating a new scope. If I've put Get-SPInventory into a file named MyFunction.ps1, I can dot source it like this:

PS C:\> . c:\functions\myfunction

Note the lone period, followed by a space, that precedes the script's path and file name. That tells the shell to run the script in the current scope, meaning everything that happens in the script will stick around after the script finishes running. The result is that the Get-SPInventory script is now defined in the shell and you can use it right from the command line, almost as if it were a cmdlet.

Don Jones is a cofounder of Concentrated Technology and author of numerous IT books. Read his weekly Windows PowerShell tips or contact him at