PowerShell's real power comes through in its tool-making abilities. What do I mean by a tool? A tool, in this context, is a PowerShell script, a module, a function, whatever that helps you perform some management task. That task could be creating a report, gathering information about a computer, creating a company user account, and so on.

PowerShell shines as a task-based automation language for system administration. In this huge blog post mini-course built as a PowerShell tutorial, you'll learn how to build robust automation tools using PowerShell and a little time. This PowerShell tutorial will teach you from having nothing all the way up to a PowerShell script that can query and return information across all of your servers.

Not only will you learn how to write a PowerShell script but you'll also learn the mindset behind writing a script like this. You'll see how a PowerShell expert thinks and how he approaches writing a PowerShell tool.

Not into reading 9,000 words of text? I got you. Check out this 30-minute YouTube tutorial below that covers this scenario along with some code improvements to easily run these checks in parallel!

PowerShell Tutorial Instructions and Structure

This mini-course is meant to be followed along from top to bottom. If you plan on building the tool completely, please don't skip a section because all sections depend on one another. I also highly encourage you to follow along in this PowerShell tutorial mini-course if you're on mobile.

After you finish this mini-course, I highly encourage you to take the next step and watch my popular 4-hour Pluralsight course PowerShell Toolmaking Fundamentals!

You will also find asides called challenges. These challenges will ask you to take the task one step further on your own. These challenges give you the opportunity to experiment on your own and try to make this tool even better than the examples we're covering here.

Look for challenges using the CHALLENGE keyword like so:

CHALLENGE: Try to write MOAR PowerShell!

Finally, you'll occasionally see asides that stop to explain an important concept but have no bearing on the tutorial itself. These asides are pointers to take into consideration. Asides will look similar to challenges but we will be prefaced with ASIDE:.

ASIDE: Some informative piece of information.
If you'd like to support my work and need a PDF, EPUB or MOBI edition of this post including any and all updates that may be added to it over time, be sure to check out the eBook Building a PowerShell Tool: A Mini-Course. This eBook is where any future improvements to this material will be added.

Each post will contain code snippets which will always represent the PowerShell script you'll be working with. There may be instances where you will see output the script returns. In those cases, it will be clearly defined.

ASIDE: This PowerShell tutorial mini-course is a sample from my book PowerShell for SysAdmins. If you want to learn PowerShell or pick up some tricks of the trade, be sure to check it out!

Prerequisites

This blog post series is going to be hands-on all of the way. To follow along to the T, make sure you've got a few prereqs in order:

  • Beginner to beginner-intermediate knowledge of PowerShell
  • Scripting computer is a member of an Active Directory (AD) domain
  • Windows PowerShell 5.1 (All code was tested using this version) If you don't know what version you have check out How to Check your PowerShell Version (All the Ways!)
  • You have RSAT installed. You can get this by running Install-WindowsFeature RSAT-AD-PowerShell on Windows 10. You will need this to run the *Ad* PowerShell commands below.
  • You are logged in with an AD user that has rights to query computer accounts in AD. If you can run Get-AdUser -Identity $env:USERNAME without an error, you're good.
  • You can run Get-ChildItem -Path "\\<someserver>\c$" on all servers you intend to query
  • You can run Get-CimInstance -ComputerName <someserver> -ClassName 'Win32_OperatingSystem' on all servers you intend to query
  • You can run Get-Service -ComputerName <someserver> on all servers you intend to query

Script Scaffolding

Since we're going to be building scripts in this PowerShell tutorial and not just executing code at the console, we need first to create a new PowerShell script. Go ahead and create a script called Get-ServerInformation.ps1. I've placed mine in a folder at C:\ServerInventory. �We'll be continually adding code to this script throughout the post.

Defining the Final Output

Before we get started coding, it's always important to make a "back of the napkin" plan of how you want the output to look like when you're done. This simple sketch is a great way to measure progress, especially when building large scripts.

For this server inventory script, you've decided that when the script ends, you'd like an output to the PowerShell console that looks like this with an example server.

ServerName    IPAddress    OperatingSystem    AvailableDriveSpace (GB)   Memory (GB)    UserProfilesSize (GB)    StoppedServices
MYSERVER      x.x.x.x      Windows.....       10                         4              50.4                     service1,service2,service3

Now that you know what you want to see let's now dive into how to make it happen.

Discovery and Script Input

Before we can start gathering information, we first need to decide how to tell our script what to query. For this project, we're going to be collecting information from multiple servers.

Because I'm assuming most readers have Active Directory in their environment, I'll be querying Active Directory for server names and using them. You could query server names from text files, as an array in the PowerShell script, from the registry, from WMI, from databases and anything else you can think of though. It doesn't matter. As long as you can get an array of strings representing server names into your script somehow you're good to go.

We've got all of our servers in a single OU. If you don't, that's OK; you will just have to read computer objects in each of them with a loop. Our first task is reading all of the computer objects in the OU.

In my environment, all of my servers are in the Servers OU, and my domain is called powerlab.local.

To retrieve computer objects from AD, I'll use the Get-AdComputer command. This command will return all of the AD computer objects for the servers I'm interested in.

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter *

The $servers variable will contain AD objects for each computer account as shown below.

DistinguishedName : CN=SQLSRV1,OU=Servers,DC=Powerlab,DC=local
DNSHostName       : SQLSRV1.Powerlab.local
Enabled           : True
Name              : SQLSRV1
ObjectClass       : computer
ObjectGUID        : c288d6c1-56d4-4405-ab03-80142ac04b40
SamAccountName    : SQLSRV1$
SID               : S-1-5-21-763434571-1107771424-1976677938-1105
UserPrincipalName :

DistinguishedName : CN=WEBSRV1,OU=Servers,DC=Powerlab,DC=local
DNSHostName       : WEBSRV1.Powerlab.local
Enabled           : True
Name              : WEBSRV1
ObjectClass       : computer
ObjectGUID        : 3bd2da11-4abb-4eb6-9c71-7f2c58594a98
SamAccountName    : WEBSRV1$
SID               : S-1-5-21-763434571-1107771424-1976677938-1106
UserPrincipalName :
$servers variable value with AD computer accounts
CHALLENGE: Instead of using Active Directory to pull computer names, create a text file of server names or a CSV file and see if you use that to return the appropriate server names that will be assigned to the $servers variable. If you do it right, you should be able to switch out Active Directory with a text file using a single line of code.

Notice that instead of setting the SearchBase parameter argument directly, I've defined a variable. Get used to doing this. Every time you've got a specific configuration item like this, it's always a good idea to put it into a variable.

You never know when you'll need to use that value again somewhere else. Also, notice that I'm returning the output of Get-AdComputer to a variable as well. Since we're going to be doing some other things against these servers, we'll want to reference all of the server names later in the script.

This returns the AD objects, but we're just looking for the server name. We can narrow this down by only returning the Name property by using Select-Object.

$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

The $servers value now looks like:

SQLSRV1
WEBSRV1

Querying Each Server

Now that we have a way to gather up a list of the servers we're interested in, we can soon begin to iterate over each of these servers to gather some information to match the output we've defined earlier. But first, we need to create a loop to make it possible to query every server in our array without repeating ourselves.

To not make any assumptions that your code will work immediately (it usually doesn't), I like to start slow and "test" each piece as I'm building it. In this example, instead of diving in and figuring out all the other code to make this happen, I'll put a Write-Host reference in just to ensure the script is returning the server name as I expect.

foreach ($server in $servers) {
    Write-Host $server
}
ASIDE: Normally it's not recommended to use Write-Host but it works great when scaffolding out code like this or testing. Here's a great article explaining why.

Once I run my script, I can see that the value of $server gets returned for each server in my array.

PS> C:\ServerInventory\Get-ServerInformation.ps1

SQLSRV1
WEBSRV1

Great! We've got a loop setup that's iterating over each of the server names in our array. One task complete!

Building a Common Object Type

When most people first start writing PowerShell code, they lack context. They don't know what could happen by the time they finally get that perfect script built. As a result, they create scripts in a zig-zag pattern by bouncing all over the place, linking things together, going back, doing it over again and so on. If we didn't take a moment and explain this now and the purposeful progression of code in this post, I'd be doing you a disservice.

I know from experience just by looking at the output, we're going to have to use a few different commands that pull information from various sources like WMI, the file system, and Windows services. Each of these sources is going to return a different kind of object that would look terrible if combined.

If we'd just try to jump in and start writing code to gather all of this stuff, the output would look something like this example below.

Below I'm querying a service and trying to get memory from a server at the same time. The objects are different, the properties on those objects are different, and it looks terrible if you attempt to merge all of that output.

Status   Name               DisplayName
------   ----               -----------
Running  wuauserv           Windows Update

__GENUS              : 2
__CLASS              : Win32_PhysicalMemory
__SUPERCLASS         : CIM_PhysicalMemory
__DYNASTY            : CIM_ManagedSystemElement
__RELPATH            : Win32_PhysicalMemory.Tag="Physical Memory 0"
__PROPERTY_COUNT     : 30
__DERIVATION         : {CIM_PhysicalMemory, CIM_Chip...
__SERVER             : DC
__NAMESPACE          : root\cimv2
__PATH               : \\DC\root\cimv2:Win32_PhysicalMemory...

Let me just save you some time now and hopefully in the future to make you think ahead of time before diving in.

Since we'll be combining different kinds of output, we have to create our own type of output and no; it's not going to be as complicated as you may think. PSCustomObject type objects are generic objects in PowerShell that allow you to add your own properties easily and are perfect for what we're doing here.

To get an output like we want for each server, every object that's returned from our script must be of the same type. Since one command can't pull all of this information and "convert" these items to a particular object type, we can do it on our own.

We know the headers of the output we need and, by now, I hope you understand that these "headers" will always be object properties. Let's create a custom object with the properties we'd like to see in the output.

I've called this object $output because of this the object that our script is going to return after we've populated all of the properties in it.

Below I'm creating a hashtable and casting it to a pscustomobject object to show you what the final output will be. However, in the script you'll be creating, you'll first create a hashtable, add some things to it and you'll then cast it to a pscustomobject object to be returned to the console.

$output = [pscustomobject]@{
    'ServerName'                  = $null
    'IPAddress'                   = $null
    'OperatingSystem'             = $null
    'AvailableDriveSpace (GB)'    = $null
    'Memory (GB)'                 = $null
    'UserProfilesSize (GB)'       = $null
    'StoppedServices'             = $null
}
ASIDE: The concept of casting is a term that refers to "converting" one object to another. Casting is a common programming term. In this instance, you are "converting" a hashtable with key/value pairs and making that hashtable a object of type pscustomobject with the hashtable keys as object properties and the hashtable values as object property values. For a breakdown of how casting works, check out Using PowerShell to cast objects.

If we copy this to the console and then return it with a formatting cmdlet Format-Table, we can see the headers we're looking for.

PS> $output | Format-Table -AutoSize

ServerName IPAddress OperatingSystem AvailableDriveSpace (GB) Memory (GB) UserProfilesSize (GB) StoppedServices
---------- --------- --------------- ------------------------ ----------- --------------------- ---------------
The Format-Table command is one of a few format commands in PowerShell that are meant to be used as the last command in the pipeline. They transform current output and display it differently. In this instance, I'm telling PowerShell to transform my object output into a table format and auto size the rows based on the width of the console.

Once we've got our custom output object defined, we can now add this inside of our loop to make every server return one. Since we already know the server name, we can already set this object property.

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    [pscustomobject]$output
}

Notice that instead of immediately casting the hashtable to a pscustombject object via [pscustomobject]@{}, I've instead created the hashtable and when I'm finishing modifying it, I'm then casting it to the pscustomobject.

ASIDE: Notice the [ordered] type on line 6. When you create a hashtable with just @{}, PowerShell will not maintain the order of the keys if the hashtable is modified in any way. To guarantee the keys stay in the order you initially defined them, you can preface the @{} hashtable declaration with [ordered]. This ensure all keys stay in the same order.

Since we only care about the object being that type when it's output, it's simpler to keep the property values in a hashtable first and then convert it to the pscustomobject at the end of each loop iteration.

I can now rerun the script and you can see we've already got some information in there. We are well on our way.

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ --------------- --------------- --------- -----------
SQLSRV1
WEBSRV1

The Tool (Thus Far)

If you've followed all instructions in this section, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

You've now scaffolded out a PowerShell script that will allow you to query various information from servers and return them all in a common object type (pscustomobject).

Next up, let's begin plugging in functionality to your script by enumerating files and returning folder size information from your servers.

Enumerating User Profiles

Now that we've got our foundation built, it's now a matter of figuring out how to pull the information we need from each server and return the appropriate properties. Let's now focus on getting the value for UserProfileSize (GB) for each server.

Perhaps I know some Citrix or Remote Desktop Services servers have large user profiles. I'd like to see how much space is being consumed by all of these profiles located in C:\Users of each server.

Before we can gather this information for all servers and add it to the script, we must first figure out how to do it with one server. Since I know the folder path, I'll first see if I can query all files under all of the user profile folders on just one of my servers. When I run Get-ChildItem -Path \\WEBSRV1\c$\Users -Recurse -File, I can immediately see it's returning all of the files and folders in all user profiles, but I don't see anything related to size.

PS> Get-ChildItem -Path \\WEBSRV1\c$\Users -Recurse -File

PSPath            : Microsoft.PowerShell.Core\FileSystem::...
PSParentPath      : Microsoft.PowerShell.Core\FileSystem::...
PSChildName       : file.log
PSProvider        : Microsoft.PowerShell.Core\FileSystem
PSIsContainer     : False
Mode              : -a----
VersionInfo       : File:             \\WEBSRV1\c$\Users\Adam\file.log
                    InternalName:
                    OriginalFilename:
                    FileVersion:
                    FileDescription:
                    Product:
                    ProductVersion:
                    Debug:            False
                    Patched:          False
                    PreRelease:       False
                    PrivateBuild:     False
                    SpecialBuild:     False
                    Language:

<SNIP>

To get all of the properties Get-ChildItem returns, you can pipe the output to Select-Object and use specify all properties by using an asterisk as the value for Property as shown below.

PS> Get-ChildItem -Path \\WEBSRV1\c$\Users -Recurse -File |
Select-Object -Property *

When you run the above command, you'll see a Length property. This is how large the file is in bytes. Knowing this, you'll now have to figure out how to add up all of these Length property values for all files in each server's C:\Users folder.

Luckily, PowerShell makes this easy with the Measure-Object cmdlet. This cmdlet accepts input from the pipeline and will automatically add up a value for a specific property.

PS> Get-ChildItem -Path '\\WEBSRV1\c$\Users\' -File -Recurse | Measure-Object -Property Length -Sum

Count    : 15
Average  :
Sum      : 6000000554
Maximum  :
Minimum  :
Property : Length

We now have a property (Sum) we can use to represent the total user profile size in our output.

At this point, we will incorporate this code into our loop and set the appropriate property in our $output hashtable. Since we just need the Sum property, we'll enclose the command in parentheses and just reference the Sum property as shown below.

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    $output.'UserProfilesSize (GB)' = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    [pscustomobject]$output
}
Adding code for the UserProfileSize (GB) property
ASIDE: Note that $output.ServerName and $output.'UserProfilesSize (GB)' are a little different. 'UserProfilesSize (GB)' is surrounded by single quotes and ServerName is not. Why is that? PowerShell allows you to create and reference object properties with spaces in them only if surrounded by single or double quotes. This tells PowerShell to treat anything inside of the quotes as the object property.

Your script now outputs:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ --------------- --------------- --------- -----------
SQLSRV1                   6000036245
WEBSRV1                   6000000554
Finding user profile size

I now see the total size of the user profile size property, but it's not in GB. We've calculated the sum of Length and Length is in bytes. Converting measurements like this is easy in PowerShell; simply divide bytes by 1GB, and you've got your number.

$userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum

$output.'UserProfilesSize (GB)' = $userProfileSize / 1GB
Assigning the UserProfilesSize (GB) property

When this run, you'll see that the values are now represented in GB but have a whole lot of decimals.

You don't need to see the total user profile size to 12 digits so you can do a little rounding using the Round() method on the [Math] class making the output look much better. The [Math] class is a .NET class (not specifically PowerShell) that you can reference in PowerShell. Type [Math] in your console followed by :: and start hitting the tab key. You'll discover all kinds of methods to perform many different math operations.

Once I round the output found in the Assigning the UserProfileSize (GB) property example, you will then have a nicely rounded number. I'll go ahead and place this in my Get-ServerInformation.ps1 script.

$userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum

$output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,0)

The Tool (Thus Far)

If you've followed all instructions thus far, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,0)

    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

You've continued building your server inventory script in this section by adding on functionality to find user profile size. Nice job! Using the knowledge you've gained in this post, you should now have the ability to pull file information of all kinds and add that to the final report.

Next up, you're going to begin querying WMI. WMI is where most of the information we need to pull is located so it's spread across two posts to query disk free space, server operating system and memory.

We've now got five more values to fill. For four of values, we can use WMI. WMI has just about anything you can think of about a computer. For our script, we're going to be pulling information about hard drive space, the operating system version, the server's IP address and how much memory it has.

PowerShell has two commands to query WMI; Get-WmiObject and Get-CimInstance. The Get-WmiObject command is older and not as flexible as Get-CimInstance mainly because Get-WmiObject only uses DCOM to connect to remote computers while Get-CimInstance, by default, uses WSMAN and can optionally use DCOM as well. All ongoing effort will be put into Get-CimInstance by Microsoft so that's the command we'll be sticking with. For a detailed breakdown of CIM vs. WMI, check out this blog post on the Hey Scripting Guy blog by Richard Siddaway.

The hardest part of querying WMI is not querying WMI; it's first figuring out where the information you're looking for is hidden. To skip all of that research, let me offer you the answer sheet to this script ahead of time. All storage resource usage is in Win32_LogicalDisk, information about the operating system is in Win32_OperatingSystem, Windows services are all represented in Win32_Service, any network adapter information is in Win32_NetworkAdapterConfiguration, and memory information is in Win32_PhysicalMemory.

Now that we have those answers out of the way, let's now see how we can use Get-CimInstance to query the properties we're looking for from these WMI classes.

Finding Free Disk Space

We first need to get the available hard drive space for all disks on the servers from Win32_LogicalDisk. By using one of my server's as a test, I can see that we don't even need to dig into all of the properties using Select-Object this time, the FreeSpace property is shown by default.

PS> Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_LogicalDisk 
| Select-Object -First 1

DeviceID DriveType ProviderName VolumeName Size        FreeSpace   PSComputerName
-------- --------- ------------ ---------- ----        ---------   --------------
C:       3                                 42708496384 34145906688 sqlsrv1

Knowing this, I can now narrow down on the FreeSpace property just as we did earlier when we referenced the Sum property for Measure-Object by wrapping the command in parentheses and referencing the property name.

PS> (Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_LogicalDisk).FreeSpace

34145906688

Managing Arrays of Objects

In the example above, to find free space for a disk, it assumed that you only had a single logical disk instance, but this isn't always the case. In some instances, you may have many disks and you'd like to return all of them. The same will go for the network adapters of the server which we'll go over a little later.

When you run the above example on a machine with many logical disk instances, you'll get an output like this showing free space on all of the disks. Although helpful to know, you have no idea which number correlates to which disk.

561852416
50806784
60476891136
60476891136
60476891136
60476891136
60476891136
60476891136
60476891136

Instead, you'll need to pull the disk label and free space which creates rich objects with properties to return unlike a simple string. Instead of assuming a single logical disk, we can use Select-Object to find return just the device label and amount of free space as shown below.

Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_LogicalDisk 
| Select-Object -Property DeviceID,FreeSpace

DeviceID   FreeSpace
--------   ---------
C:         563462144
D:
R:          98529280
S:          50806784
T:       59132260352
U:       59132260352
V:       59132260352
W:       59132260352
X:       59132260352
Y:       59132260352
Z:       59132260352

Now you can see what the free space looks like on all disks.

We've now narrowed down the values but it's again in bytes (this is a common thing in WMI), so we'll have to divide by 1GB to get these numbers represented in gigabytes.

PS> Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_LogicalDisk 
| Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={$_.Freespace / 1GB }}

A good method to modify the object property that's returned is to use a concept called calculated properties. Calculated properties all you to dynamically return object properties by running code in a script block. Converting free space from bytes to gigabytes is an excellent use-case in this scenario.

The output from this command will now look something like this:

DeviceID          FreeSpace
--------          ---------
C:        0.524124145507812
D:                        0
R:       0.0917625427246094
S:       0.0473175048828125
T:         55.0737380981445
U:         55.0737380981445
V:         55.0737380981445
W:         55.0737380981445
X:         55.0737380981445
Y:         55.0737380981445
Z:         55.0737380981445

We'll now add this code to our inventory script by assigning the array of objects to the AvailableDriveSpace (GB) property to make the ongoing script look like this:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)

    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={$_.Freespace / 1GB }}
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

An example output should now look like this:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ --------------- --------------- --------- -----------
SQLSRV1                   6.2  { @{DeviceID =C:; 
                                        FreeSpace=0.523658752441406 },
                                    @{DeviceID=D:; FreeSpace=0 }... }
WEBSRV1                   6.2  { @{DeviceID =C:; 
                                        FreeSpace=0.523658752441406 },
                                    @{DeviceID=D:; FreeSpace=0 }... }

Again, you don't need the freespace represented with a bunch of digits behind the decimal point. Let's use the [math] type's Round method again to round the free space to the nearest tenth of a gigabyte as shown below.

$output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk |
Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}

The output of $output.'AvailableDriveSpace (GB)' should now look like below:

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ --------------- --------------- --------- -----------
SQLSRV1                   6.2 { @{DeviceID =C:; 
                                        FreeSpace=0.5 },
                                    @{DeviceID=D:; FreeSpace=0 }... }
WEBSRV1                   6.2 { @{DeviceID =C:; 
                                        FreeSpace=0.5 },
                                    @{DeviceID=D:; FreeSpace=0 }... }
CHALLENGE: The output you see for AvailableDriveSpace (GB) doesn't look that great, does it? The reason is because PowerShell is trying to display multiple objects in a single row. It's squeezing everything together. How would you sum up free space for all drives together to make the script only display the sum of free space for all disks? Hint: Measure-Object -Sum.

Things are coming right along for you in this PowerShell tutorial!

Finding Operating System Information

Going with the same pattern as before, we'll now query a single server, find the appropriate property and then add it to our foreach loop.

From now on, I will simply be adding lines to our foreach loop. The process to narrow down the class, class property and the property value are the same for any single value you'll be querying from WMI. Just follow the same pattern:

$output.'<PropertyName>' = (Get-CimInstance -ComputerName <ServerName> -ClassName <WMIClassName>).<WMIClassPropertyName>

Adding our next value gives us a script that now looks like this:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Output of the server inventory script now looks like this:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem                           StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ ---------------                           --------------- --------- -----------
SQLSRV1                   6.2         31.8005790710449 Microsoft Windows Server 2016 Standard
WEBSRV1                   6.2         34.5973815917969 Microsoft Windows Server 2012 R2 Standard

Finding Memory

Moving onto the next property (Memory), we'll be using the Win32_PhysicalMemory class. Testing our query on a single server again yields the information we're looking for. In this case, the property is Capacity.

PS> Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_PhysicalMemory

Caption              : Physical Memory
Description          : Physical Memory
InstallDate          :
Name                 : Physical Memory
Status               :
CreationClassName    : Win32_PhysicalMemory
Manufacturer         : Microsoft Corporation
Model                :
OtherIdentifyingInfo :
PartNumber           : None
PoweredOn            :
SerialNumber         : None
SKU                  :
Tag                  : Physical Memory 0
Version              :
HotSwappable         :
Removable            :
Replaceable          :
FormFactor           : 0
BankLabel            : None
Capacity             : 2147483648
<SNIP>

Each instance under Win32_PhysicalMemory represents a "bank" of RAM. Think if this as physical "stick" of RAM in a server. It just so happens that my SQLSRV1 server only has one bank of memory. However, you will undoubtedly have servers with lots more banks of RAM than this.

Since we're looking for total memory in a server, we'll have to follow the same routine as we did when getting user profile size. We'll have to add up the value of Capacity across all of the instances. Lucky for us, the Measure-Object cmdlet works across any number of object types. As long as the property is a number, it can add them all up.

Again, since Capacity was represented in bytes, we'll convert it to the appropriate label.

PS> (Get-CimInstance -ComputerName sqlsrv1 -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB

2

Our script is growing!

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -ComputerName $server -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Output now looks like this:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem                           StoppedServices IPAddress Memory (GB)
---------- --------------------- ------------------------ ---------------                           --------------- --------- -----------
SQLSRV1                   6.2                     31.8 Microsoft Windows Server 2016 Standard                                        2
WEBSRV1                   6.2                     34.6 Microsoft Windows Server 2012 R2 Standard                                     2

Finding Server IP Addresses

We're nearing the close of our server inventory script, and we've made it to the last information source we'll be getting from WMI. For our final WMI class, we'll be grabbing information from Win32_NetworkAdapterConfiguration.

The task of finding the IP address has been saved for last because the IP address of the server isn't as cut and dry as finding a value and then adding it to our $output hashtable. We'll have to do some filtering to narrow it down.

Let's first see what the output looks like using the same method we have been so far.

PS> Get-CimInstance -ComputerName SQLSRV1 -ClassName Win32_NetworkAdapterConfiguration

ServiceName      DHCPEnabled                            Index                                  Description                            PSComputerName
-----------      -----------                            -----                                  -----------                            --------------
kdnic            True                                   0                                      Microsoft Kernel Debug Network Adapter SQLSRV1
netvsc           False                                  1                                      Microsoft Hyper-V Network Adapter      SQLSRV1
tunnel           False                                  2                                      Microsoft ISATAP Adapter               SQLSRV1

Up front, you can see that the default output doesn't show the IP address, but this hasn't stopped us before. However, it also doesn't return a single instance. This server has three network adapters on it. How do we narrow down the one that has the IP address we're looking for?

First, we'll have to see all of the properties using Select-Object. Using Get-CimInstance -ComputerName SQLSRV1 -ClassName Win32_NetworkAdapterConfiguration | Select-Object -Property *, I scroll through the output and notice some that don't have anything for the IPAddress property and only one with the IP address I'm expecting. It is below.

DHCPLeaseExpires             :
Index                        : 1
Description                  : Microsoft Hyper-V Network Adapter
DHCPEnabled                  : False
DHCPLeaseObtained            :
DHCPServer                   :
DNSDomain                    : Powerlab.local
DNSDomainSuffixSearchOrder   : {Powerlab.local}
DNSEnabledForWINSResolution  : False
DNSHostName                  : SQLSRV1
DNSServerSearchOrder         : {192.168.0.100}
DomainDNSRegistrationEnabled : True
FullDNSRegistrationEnabled   : True
IPAddress                    : {192.168.0.40, fe80::e4e1:c511:e38b:4f...
IPConnectionMetric           : 20
IPEnabled                    : True
<SNIP>

How are we supposed to build a script that can find this adapter on every server? This current adapter's name is Microsoft Hyper-V Network Adapter, but that's not going to be the case for other servers. We need to find some standard criterion to filter on so that it can apply to all servers.

From experience, I can tell you that the IPEnabled property is your ticket. When this property is set to True, that means that the TCP/IP protocol is bound to this NIC which is a prerequisite to having an IP address. If we can narrow down the NIC that has the IPEnabled property set to True, we'll have the adapter we're looking for.

When filtering WMI instances, it's always best to use the Filter parameter on Get-CimInstance. There's a motto in the PowerShell community that says "filter left." This saying means, if you have the chance, always filter output as far to the left as possible.

Don't use Where-Object unless you have to. The performance will be much faster instead due to the lack of overhead of processing unnecessary objects across the pipeline.

The Filter parameter on Get-CimInstance uses Windows Query Language (WQL). WQL is a small subset of SQL. The Filter parameter expects the same constraint WHERE clause syntax as SQL would.

For example, in WQL, if we wanted all of the Win32_NetworkAdapterConfiguration class instances with the IPEnabled property set to True, we could use SELECT * FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = 'True'. Since we're already specifying the class name for the ClassName parameter argument in Get-CimInstance, we just need to specify IPEnabled = 'True' for the Filter. This will return only the network adapter with the IP address.

Get-CimInstance -ComputerName SQLSRV1 -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'" |
Select-Object -Property *

Now that we have a single WMI instance and we also know the property we're looking for (IPAddress), let's see what it looks like when querying a single server.

PS> (Get-CimInstance -ComputerName SQLSRV1 -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress

192.168.0.40
fe80::e4e1:c511:e38b:4f05
2607:fcc8:acd9:1f00:e4e1:c511:e38b:4f05

Ouch! It's got IPv4 and IPv6 references in there. We're going to have to filter some more elements out. Because WQL can't filter deeper than the IPAddress property value, we're going to have to parse out the IPv4 address. Doing some investigation, I saw that all of the addresses were enclosed with curly braces separated by a comma. This is a good indication that this property isn't stored as one big string but rather than an array.

IPAddress                    : {192.168.0.40, fe80::e4e1:c511:e38b:4f0...

We can check this out by attempting to reference indexes just like we do an array to see if an index only returns the IPv4 address.

PS> (Get-CimInstance -ComputerName SQLSRV1 -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]

192.168.0.40
You'll find that the above code only can query a single NIC adapter. �How would you update this to account for multiple adapters? Hint: Take a look at how you managed multiple disks to find free space!

It looks like we're in luck. The IPAddress property is an array where we can specify the first element that's giving us only the IPv4 address. At this point, we've got our value, and we can add it to our script.

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -ComputerName $server -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance -ComputerName $server -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Output will now look like this:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem                           StoppedServices IPAddress       Memory (GB)
---------- --------------------- ------------------------ ---------------                           --------------- ---------       -----------
SQLSRV1                   6.2                     31.8 Microsoft Windows Server 2016 Standard                    192.168.0.40    2
WEBSRV1                   6.2                     34.6 Microsoft Windows Server 2012 R2 Standard                 192.168.0.70    2

The Tool (Thus Far)

If you've followed all instructions thus far, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)  
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -ComputerName $server -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance -ComputerName $server -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Querying Windows Services

One more element to go in our script! Finally, we'll need to pull the total number of services on the servers that are stopped. Again, using a single server for testing first, let's first find all of the services that are stopped on that server. To do that, we can use the Get-Service command and pipe the output of all services to Where-Object where we can then only return the ones that have a Status of Stopped via Get-Service -ComputerName sqlsrv1 | Where-Object { $_.Status -eq 'Stopped' }.

Get-Service is returning whole objects with all of their properties. We're interested in knowing the services themselves. We need a summary view of the total number of stopped services.

To find the total number of stopped services, we can use the Measure-Object cmdlet again. This time, we'll use this cmdlet to find the total number of objects; not a sum of object property numbers. To find the total number of stopped services, pass the output from Get-Service to Measure-Object and reference the Count property as shown below. You should see that this returns the number of stopped services.

PS> (Get-Service -ComputerName sqlsrv1 | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count

3

I'll now again add this line to my script and see what I get.

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -ComputerName $server -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance -ComputerName $server -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    $output.StoppedServices = (Get-Service -ComputerName $server | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Sample output now will look like this:

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName UserProfilesSize (GB) AvailableDriveSpace (GB) OperatingSystem                           StoppedServices
---------- --------------------- ------------------------ ---------------                           ---------------
SQLSRV1                   6.2                     31.8 Microsoft Windows Server 2016 Standard    3
WEBSRV1                   6.2                     34.6 Microsoft Windows Server 2012 R2 Standard 5

It looks OK but where'd the other properties go? At this point, there's no room left in the console window. Removing the Format-Table reference allows us again to see all of the values.

PS> C:\ServerInventory\Get-ServerInformation.ps1 | Format-Table -AutoSize

ServerName               : SQLSRV1
UserProfilesSize (GB)    : 6.2
AvailableDriveSpace (GB) : 31.8
OperatingSystem          : Microsoft Windows Server 2016 Standard
StoppedServices          : 3
IPAddress                : 192.168.0.40
Memory (GB)              : 2

ServerName               : WEBSRV1
UserProfilesSize (GB)    : 6.2
AvailableDriveSpace (GB) : 34.6
OperatingSystem          : Microsoft Windows Server 2012 R2 Standard
StoppedServices          : 5
IPAddress                : 192.168.0.70
Memory (GB)              : 2

Success! We've finished our script!

The Tool (Thus Far)

If you've followed all instructions thus far, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -ComputerName $server -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -ComputerName $server -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -ComputerName $server -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance -ComputerName $server -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    $output.StoppedServices = (Get-Service -ComputerName $server | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Refactoring

Now that you've gone through a somewhat complicated script and learned some common problems you may run into, rather than declaring victory and moving on, let's reflect a little.

Writing code is an iterative process. You can start out with a set of things you want to accomplish, accomplish them but at the end, you end up with code that just looks bad and can be improved on. Our script does exactly what we want it to do now, but we could do it a better way. How?

Recall how we talked about the DRY method: don't repeat yourself. I see a lot of instances where I'm repeating myself in this script. We've got lots of Get-CimInstance references where we're using the same parameters over and over again. We're also making a lot of calls to WMI to the same server. There are a couple of ways we can make this script more efficient.

First of all, all of the CIM cmdlets have a CimSession parameter. This parameter allows you to create a single CIM session once and then reuse it. Rather than creating a temporary session, using it and tearing it down again, we can use a single session, use it all we want and then tear it down. The concept is similar to Invoke-Command's Session parameter.

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $cimSession = New-CimSession -ComputerName $server
    
    $output.ServerName = $server
    
    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance -CimSession $cimSession -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance -CimSession $cimSession -ClassName Win32_OperatingSystem).Caption
    
    $output.'Memory (GB)' = (Get-CimInstance -CimSession $cimSession -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance -CimSession $cimSession -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    $output.StoppedServices = (Get-Service -ComputerName $server | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count
    
    Remove-CimSession -CimSession $cimSession
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1

Now I'm reusing a single CIM session. Next, I'm still referencing that CIM session a lot. I need to create a parameter called CIMSession and just reuse it across all Get-CimInstance references. To do that, I can use PowerShell splatting by creating a hash table with the parameter and argument and using the @ symbol followed by the hashtable name in each Get-CimInstance reference. Now you can also see I've eliminated the $cimSession variable all together as well.

The Tool (Thus Far)

If you've followed all instructions thus far, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'                  = $null
        'IPAddress'                   = $null
        'OperatingSystem'             = $null
        'AvailableDriveSpace (GB)'    = $null
        'Memory (GB)'                 = $null
        'UserProfilesSize (GB)'       = $null
        'StoppedServices'             = $null
    }
    $getCimInstParams = @{
        CimSession = (New-CimSession -ComputerName $server)
    }
    $output.ServerName = $server

    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance @getCimInstParams -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID,@{Name='FreeSpace';Expression={ [Math]::Round(($_.Freespace / 1GB),1) }}
    
    $output.'OperatingSystem' = (Get-CimInstance @getCimInstParams -ClassName Win32_OperatingSystem).Caption

    $output.'Memory (GB)' = (Get-CimInstance @getCimInstParams -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance @getCimInstParams -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    $output.StoppedServices = (Get-Service -ComputerName $server | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count

    Remove-CimSession @getCimInstParams
    
    [pscustomobject]$output
}
C:\ServerInventory\Get-ServerInformation.ps1-

Building an HTML Report

Now that you have an excellent report on the configuration of our servers, you may want to view this information in different ways. In this section, you'll take the console output you've been working hard on and convert that output to a beautiful-looking HTML report.

ASIDE: To complete this section you will need to have completed the challenge referred to in the Finding Disk Space section.

Our first step is to create an empty array to store the results. You need to do this because we first need to capture the results to process all at once a bit later.

On line 5, we can capture all of the objects coming out of the foreach loop by assigning the foreach loop equal to $report.

$report = foreach ($server in $servers) {

This collects each object that would have been returned to the console into an array so we can work with all objects after the script has run.

Now add a line $report on 37 (the line right after the foreach block) and run the script again. You should see the same output although this time, it may take a little while longer to return because you're collecting up all of the objects in an array first and then releasing them instead of returning them as they're processed.

We want it to look nice, so we create a style sheet. A CSS style sheet allows you to format the HTML table the servers will show up in, tweak text alignment and set the background color. CSS is beyond the scope of this article, but you can get further information at W3Schools and many other resources.

Try playing about with the background color. The below example is green. This snippet should be placed outside of the foreach loop. You'll need to insert this anywhere above line 3.

$Header = @"
<style>
    table {
        font-family: "Trebuchet MS", Arial, Helvetica, sans-serif;
        border-collapse: collapse;
        width: 100%;
    }
    th {
        padding-top: 12px;
        padding-bottom: 12px;
        text-align: left;
        background-color: #4CAF50;
        color: white;
    }
</style>
"@

Next, you'll create an HTML fragment for each server. HTML fragments are handy because you can build sections within the final web page.

On line 33, pipe the objects in the array to the ConvertTo-Html cmdlet. This cmdlets transforms objects into their HTML representation and by using the Fragment parameter, we can piece all of these together when we return the final report.

$reportfinal = $report | ConvertTo-Html -Fragment
Building the HTML body

The final step is to stitch this all together. To do that, use the ConvertTo-Html cmdlet again. This time, however, use the PreContent parameter. This parameter allows you to create a title for the report and insert any other HTML above the HTML body tag.

Also, you'll use the PostContent parameter to add the fragment and use the Head parameter to specify the contents of the HTML head tag. The head tag contains HTML metadata. In your case, you'll use this to insert the style sheet as shown in the Finishing the HTML report below.

In the example below, you're adding the title at the top of the page, adding all of table of server information below that and finally outputting that HTML code to a file called ServerReport.html. This code should be inserted below the $reportfinal = $report | ConvertTo-Html -Fragment line you added above in the Building the HTML body code snippet.

ConvertTo-HTML -PreContent "<h1>Server Information Report</h1>"  -PostContent $reportFINAL -Head $Header | Out-File ServerReport.html
Finishing the HTML report

Now run the script and open up ServerReport.html. The file should be in the same directory you executed the script from.

Final HTML report
CHALLENGE: Add another fragment that shows the time and date the report was run at the bottom. Hint: <BR>Report generated on $((Get-Date).ToString())</BR>

The Tool (Thus Far)

If you've followed all instructions thus far, your server inventory script will look like the below example:

$serversOuPath = 'OU=Servers,DC=powerlab,DC=local'
$servers = Get-ADComputer -SearchBase $serversOuPath -Filter * |
Select-Object -ExpandProperty Name

$Header = @"
<style>
    table {
        font-family: "Trebuchet MS", Arial, Helvetica, sans-serif;
        border-collapse: collapse;
        width: 100%;
    }
    th {
        padding-top: 12px;
        padding-bottom: 12px;
        text-align: left;
        background-color: #4CAF50;
        color: white;
    }
</style>
"@

$report = foreach ($server in $servers) {
    $output = [ordered]@{
        'ServerName'               = $null
        'IPAddress'                = $null
        'OperatingSystem'          = $null
        'AvailableDriveSpace (GB)' = $null
        'Memory (GB)'              = $null
        'UserProfilesSize (GB)'    = $null
        'StoppedServices'          = $null
    }
    $getCimInstParams = @{
        CimSession = (New-CimSession -ComputerName $server)
    }
    $output.ServerName = $server

    $userProfileSize = (Get-ChildItem -Path "\\$server\c$\Users\" -File -Recurse | Measure-Object -Property Length -Sum).Sum
    $output.'UserProfilesSize (GB)' = [math]::Round($userProfileSize / 1GB,1)
    
    $output.'AvailableDriveSpace (GB)' = Get-CimInstance @getCimInstParams -ClassName Win32_LogicalDisk | Select-Object -Property DeviceID, @{Name='FreeSpace'; Expression={ [Math]::Round(($_.Freespace / 1GB), 1) } }
    
    $output.'OperatingSystem' = (Get-CimInstance @getCimInstParams -ClassName Win32_OperatingSystem).Caption

    $output.'Memory (GB)' = (Get-CimInstance @getCimInstParams -ClassName Win32_PhysicalMemory | Measure-Object -Property Capacity -Sum).Sum /1GB
    
    $output.'IPAddress' = (Get-CimInstance @getCimInstParams -ClassName Win32_NetworkAdapterConfiguration -Filter "IPEnabled = 'True'").IPAddress[0]
    
    $output.StoppedServices = (Get-Service -ComputerName $server | Where-Object { $_.Status -eq 'Stopped' } | Measure-Object).Count

    Remove-CimSession @getCimInstParams
    
    [pscustomobject]$output
}

$reportfinal = $report | ConvertTo-Html -Fragment
ConvertTo-HTML -PreContent "<h1>Server Information Report</h1>"  -PostContent $reportFINAL -Head $Header | Out-File ServerReport.html
-

Summary

If you're new to PowerShell, building a script that queries information is one of the first types of scripts I recommend creating. There's little chance of screwing anything up!

During this mini-course, you went from a goal (laying out what the output would look like) and iteratively added on more and more functionality to the your server inventory script. This is a process you will follow over and over again in your time with PowerShell.

Define your goal, start small, get your framework laid out and begin adding piece by piece overcoming one obstacle at a time until it all comes together.

Then, when you're finished with the script, review it. See how to make it more efficient, use fewer resources and be faster. There's no way to have that kind of perspective when you first start.

Be sure to grab a copy of the final 1.0 and 2.0 (from the video) script from GitHub too.

In the end, you can sit back and revel in your success, put the cherry on top and move onto the next project!

Keep Going!

Take the skills you've learned here with you and continue building PowerShell tools by going through my popular 4-hour Pluralsight course PowerShell Toolmaking Fundamentals!

Credits

I wouldn't have been able to build this mini-course without the help of some awesome people in the PowerShell community!