PowerShell in Depth, Second Edition (2015)
Part 3. PowerShell scripting and automation
Chapter 20. Basic scripts and functions
This chapter covers
· Scripting execution scopes
· Parameterizing your script
· Outputting scripts
· Filtering scripts
· Converting a script to a function
You can accomplish many tasks in PowerShell by typing a command and pressing Enter in the shell console. We expect that most IT pros will start using PowerShell this way and will continue to do so most of the time. Eventually, you’ll probably get tired of typing the same thing over and over and want to make it more easily repeatable. Or you hand off a task to someone else and need to make sure that it’s done exactly as planned. That’s where scripts come in—and it’s also where functions come in.
20.1. Script or function?
Suppose you have some task, perhaps one requiring a handful of commands in order to complete. A script is a convenient way of packaging those commands together. Rather than typing the commands manually, you paste them into a script and PowerShell runs them in order whenever you run that script. Following best practices, you’d give that script a cmdlet-like name, such as Set-ServerConfiguration.ps1 or Get-ClientInventory.ps1. Think of a script as a “canned” PowerShell session. Instead of manually typing 10 or 100 commands, you put the same commands in a script and execute the script like a twenty-first-century batch file.
There may come a time when you assemble a collection of such scripts, particularly scripts that contain commands that you want to use within other scripts. For example, you might write a script that checks to see whether a given server is online and responding—a task you might want to complete before trying to connect to the server to perform some management activity. Or you might write a script that saves information to a log file—you can certainly imagine how useful that could be in a wide variety of other scripts. When you reach that point, you’ll be ready to start turning your scripts into functions. A function is a wrapper around a set of commands, created in such a way that multiple functions can be listed within a single script file. That way, you can use all of your so-called utility functions more easily, simply by loading their script into memory first. Eventually, you’ll be creating modules that are autoloaded by PowerShell when you start a session; we’ll cover that in chapter 25.
In this chapter, we’re going to start by doing everything in a script first. At the end, we’ll show you how to take what you’ve done and turn your PowerShell commands into a function with just a couple of extra lines of code. We’ll also show you how to run the function once you’ve created it.
20.2. Execution lifecycle and scope
The first thing to remember about scripts is that they generally run with their own scope. We’ll discuss that in more detail in chapter 22, but for now you should understand that when you do certain things within a script, those things automatically disappear when the script is finished:
· Creating a new PSDrive
· Creating new variables or assigning values to variables (which implicitly creates the variables)
· Defining new aliases
· Defining functions (which we’ll get to toward the end of this chapter)
Variables can be especially tricky. If you attempt to access the contents of a variable that hasn’t yet been created and assigned a value, PowerShell will look to see whether that variable exists in the shell itself. If it does, that’s what your script will be reading—although if you change that variable, your script will essentially be creating a new variable with the same name. It gets confusing. We do have a complete discussion of this “scope” concept in chapter 22; for now, we’re going to follow best practices and avoid the problem entirely by never attempting to use a variable that hasn’t first been created and given a value inside our script.
20.3. Starting point: a command
You don’t often sit down and just start creating scripts. Usually, you start with a problem you want to solve. The first step is to experiment with running commands in the shell console. The benefit of this approach is that you can type something, press Enter, and immediately see the results. If you don’t like those results, you can press the up arrow on your keyboard, modify the command, and try again. Once the command is working perfectly, you’re ready to move it into a script. For this chapter, let’s start with the following command:
Get-WmiObject –Class Win32_LogicalDisk –Filter "DriveType=3"
-ComputerName SERVER2 |
Select-Object –Property DeviceID,@{Name='ComputerName';
Expression={$_.PSComputerName}},Size,FreeSpace
This command uses WMI to retrieve all instances of the Win32_LogicalDisk class from a given computer. It limits the results to drives having a DriveType of 3, which specifies local, fixed disks.
Note
PSComputerName is a property introduced in PowerShell v3 to store the name of the remote computer against which the command was run. You can use the __Server property if you prefer. We recommend using PSComputerName because it’s easier to understand and for consistency (__Server isn’t available on the CIM cmdlets). Otherwise, you have to check each class and see if it has another property that’ll return the computer name.
The command then displays the drive letter, the computer’s name, the drives’ sizes, and the drives’ free space. Both space attributes are given in bytes.
20.4. Accepting input
The first thing you’ll notice about our command is that it has some hardcoded values—the computer name is the most obvious one, and the filter to retrieve only fixed local disks is another. You can imagine pasting this into a script, giving it to some less-technical colleague, and saying, “Look, if you need to run this script, you have to open it up and edit the computer name first. Don’t change anything else, though, or you’ll mess it up.” That’s sure to work out fine, right? Sure—and you’ll probably get a phone call from a confused technician every time it’s run.
It’d be far better to define a way for the users to provide these pieces of information in a controlled, manageable way so that they don’t ever need to even open the script file, let alone edit it. In a PowerShell cmdlet, that “defined way” is a parameter—so that’s what you’ll do for this script, too.
Tip
You’ll see many scripts that prompt the user for input. Don’t follow that pattern. As you’ll see, using parameters gives the best overall experience.
The following listing shows the modified script.
Listing 20.1. Get-DiskInfo.ps1
Here’s what listing 20.1 does:
· At the top of the script, you define a Param() section. This section must use parentheses—given PowerShell’s other syntax, it can be tempting to use braces, but here they’ll cause an error message.
· You don’t have to put each parameter on its own line, but your script is a lot easier to read if you do.
· Each parameter is separated by a comma—that’s why there’s a comma after the first parameter.
· You don’t have to declare a data type for each parameter, but it’s good practice to do so. In the example, $ComputerName is set to be a [string] and $driveType is an [int]—shorthand for the .NET classes System.String and System .Int32, respectively.
· Within your script, the parameters act as regular variables, and you can see where listing 20.1 inserted them in place of the hardcoded values.
· PowerShell doesn’t care about capitalization, but it’ll preserve whatever you type when it comes to parameters. That’s why the example uses the neater-looking $ComputerName and $driveType instead of $computername and $drivetype.
· The fact that the $ComputerName variable is being given to the –ComputerName parameter is sort of a coincidence. You could’ve called the script’s parameter $fred and run Get-WmiObject with –ComputerName $fred. But all PowerShell commands that accept a computer name do so using a –Computer-Name parameter, and to be consistent with that convention, listing 20.1 uses $ComputerName.
· Listing 20.1 provides a default value for $driveType so that someone can run this script without specifying a drive type. Adding safe default values is good practice. Right now, if users forget to specify $ComputerName, the script will fail with an error. That’s something you’ll correct in chapter 24.
Running a script with these parameters looks a lot like running a native PowerShell command. Because it’s a script, you have to provide a path to it, which is the only thing that makes it look different than a cmdlet:
PS C:\> C:Scripts\Get-DiskInfo –ComputerName SERVER2
Notice that you didn’t specify a –driveType parameter, but this approach will work fine. You can also provide values positionally, specifying them in the same order in which the parameters are defined:
PS C:\> C:Scripts\Get-DiskInfo SERVER2 3
Here the –driveType parameter value, 3, is included, but not the actual parameter name. It’ll work fine. You could also truncate parameter names, just as you can do with any PowerShell command:
PS C:\> C:Scripts\Get-DiskInfo –comp SERVER2 –drive 3
If parameters are truncated, the abbreviation must be capable of being resolved unambiguously from the script’s parameters. Tab completion works for script (or function) parameter names, so abbreviation can be easily avoided without any extra typing.
The end result is a script that looks, feels, and works a lot like a native PowerShell cmdlet.
20.5. Creating output
If you run the script from listing 20.1 and provide a valid computer name, you’ll notice that it produces output just fine. So why take up pages with a discussion of creating output?
PowerShell can do a lot of work for you, and it’s easy to just let it do so without thinking about what’s going on under the hood. Yet whenever something’s happening under the hood, you do need to be aware of it so that you can figure out whether it’s going to mess up your work.
So here’s the deal: Anything that gets written to the pipeline within a script becomes the output for that script. Simple rule with potentially complex results. The example script ran Get-WmiObject and piped its output to Select-Object. You didn’t pipe Select-Object to anything, but whatever it produced was written to the pipeline nonetheless. If you’d run that same command from the command line, the contents of the pipeline would’ve been invisibly forwarded to Out-Default, formatted, and then displayed on the screen—probably as a table, given that you’re selecting only a few properties. But you weren’t running that command from the command line—it was run from within a script. That means the output of the script was whatever was put into the pipeline.
In a simple example, this distinction is irrelevant. Consider the output when running the commands from the command line (using “localhost” as the computer name this time):
PS C:\> Get-WmiObject –Class Win32_LogicalDisk –Filter "drivetype=3" `
>> –ComputerName localhost |
>> Select-Object –Property DeviceID,
>> @{name='ComputerName';expression={$_.PSComputerName}},Size,FreeSpace
>>
DeviceID ComputerName Size FreeSpace
-------- ------------ ---- ---------
C: WIN-KNBA0R0TM23 42842714112 32461271040
Here’s the script and its output:
PS C:\> .\Get-DiskInfo.ps1 -computerName localhost
DeviceID ComputerName Size FreeSpace
-------- ------------ ---- ---------
C: WIN-KNBA0R0TM23 42842714112 32461238272
Looks the same, right? At least apart from some free space that has mysteriously gone missing—probably a temp file or something. Anyway, it produces the same basic output. The difference is that you can pipe the output of your script to something else:
PS C:\> .\Get-DiskInfo.ps1 -computerName localhost |
>> Where { $_.FreeSpace -gt 500 } |
>> Format-List -Property *
>>
DeviceID : C:
ComputerName : WIN-KNBA0R0TM23
Size : 42842714112
FreeSpace : 32461238272
The example piped the output of the script to Where-Object and then to Format-List, changing the output. This result may seem obvious to you, but it impresses the heck out of us! Basically, this little script is behaving exactly like a real PowerShell command. You don’t need to know anything about the black magic that’s happening inside: You give it a computer name and optionally a drive type, and it produces objects. Those objects go into the pipeline and can be piped to any other command to further refine the output to what you need.
So that’s Rule #1 of Script Output: If you run a command and don’t capture its output into a variable, then the output of that command goes into the pipeline and becomes the output of the script. Of course, having one rule implies there are others. What if you don’t want the output of a command to be the output of your script? Consider the following listing.
Listing 20.2. Test-Connectivity.ps1
param(
[string]$ComputerName
)
$status = Test-Connection -ComputerName $Computername -count 1
if ($status.statuscode -eq 0) {
Write-Output $True
} else {
Write-Output $False
}
We wrote the script in listing 20.2 to test whether a given computer name responds to a network ping. Here are a couple of usage examples:
PS C:\> .\Test-Connectivity.ps1 -computerName localhost
True
PS C:\> .\Test-Connectivity.ps1 -computerName notonline
False
Okay, let’s ignore for the moment the fact that the Test-Connection cmdlet can do this when you use its –Quiet switch. We’re trying to teach you something. The point is that you can use Write-Output to manually put a piece of information—such as $True or $False—into the pipeline within a script and that’ll become the script’s output. You don’t have to output only whatever a command produces; you can also output custom data. We’re going to get a lot more robust with what can be output in the next chapter. But that leads to Rule #2 of Script Output: Whatever you put into the pipeline by using Write-Output will also become the output of your script.
First, a warning: Whatever you choose to be the output of your script must be a single kind of thing. For example, listing 20.1 produced a WMI Win32_LogicalDisk object. Listing 20.2 produced a Boolean value. You should never have a script producing more than one kind of thing—PowerShell’s system for dealing with and formatting output can get a little wonky if you do that. For example, try running the code in the next listing.
Listing 20.3. Bad-Idea.ps1
Get-Service
Get-Process
Get-Service
The script in listing 20.3 produces two kinds of output: services and processes. PowerShell does its best to format the results, but they’re not as pretty as individually running Get-Service and Get-Process. When you run the two commands independently from the command line, each gets its own pipeline, and PowerShell can deal with the results of each independently. Run them as part of a script, as you did in listing 20.3, and two types of objects get jammed into the same pipeline. That’s a poor practice and one that PowerShell isn’t always going to deal with gracefully or beautifully. That’s Rule #3 of Script Output: Output one kind of object, and one kind only.
20.6. “Filtering” scripts
By this point in the book, you should be familiar with the Where-Object cmdlet. Its main purpose in life is to accept some criteria or comparison from you and then remove any piped-in objects that don’t meet those criteria or result in a true comparison, for example:
Get-WmiObject –Class Win32_Service |
Where {$_.StartMode –eq 'Auto' –and $_.State –ne 'Running' }
This code displays all services that should be running but aren’t. The activity happening here is filtering; in a more general sense, a filter gets a bunch of objects piped in, does something with each one (such as examining them), and then pipes out all or some subset of them to the pipeline. PowerShell provides a straightforward way to give your scripts this capability.
Note
PowerShell’s syntax includes the keyword filter, which is a special type of language construct. It’s a holdover from PowerShell v1. The construct we’re about to show you, a filtering script, supersedes those older constructs. We’ll show you how to use filter in section 20.8 but keep in mind it’s not an approach that we recommend.
Here’s the basic layout for a filtering script. This example doesn’t do anything; it’s just a template that we’ll work from:
Param()
BEGIN {}
PROCESS {}
END {}
What you have here is the Param() block, in which you can define any input parameters that your script needs. There’s one parameter that’ll get created automatically: the special $_ placeholder that contains pipeline input. More on that in a moment—just know for now that you don’t need to define it in the Param() block.
Next up is the BEGIN block. This is a script block, recognizable by its braces. Those tell you that it can be filled with PowerShell commands, and in this case those commands will be the first ones the script executes when it’s run. The BEGIN block is executed only once, at the time the first object is piped into the script.
After that is the PROCESS block, which is also a script block and is also capable of containing commands. This block is executed one time for each pipeline object that was piped into the script. So, if you pipe in 10 things, the PROCESS block will execute 10 times. Within the PROCESS block, the $_ placeholder will contain the current pipeline object. There’s no need to use a scripting construct such as ForEach to enumerate across a group of objects, because the PROCESS block is essentially doing that for you.
Once the PROCESS block has run for all of the piped-in objects, the END block will run last—but only once. It’s a script block, so it can contain commands.
PowerShell is perfectly happy to have an empty BEGIN, PROCESS, or END block—or even all three, although that’d make no sense. You can also omit any blocks that you don’t need, but we like leaving in an empty BEGIN block (for example) rather than omitting it, for visual completeness.
Let’s create another example. Suppose you plan to get a group of Win32_Logical-Disk objects by using WMI. You’ll use a WMI filter to get only local fixed disks, but from there you want to keep only those disks that have less than 20% free space. You could absolutely do that in aWhere-Object cmdlet, like so:
Get-WmiObject –Class Win32_LogicalDisk –Filter "drivetype=3" |
Where-Object {$_.FreeSpace / $_.Size –lt .2 }
But you could also build that into a simple filtering script. The following listing shows the Where-LowFreeSpace.ps1 script. Note that we’ve added a parameter for the desired amount of free space, making the script a bit more flexible.
Listing 20.4. Where-LowFreeSpace.ps1
Run the script in listing 20.4 as follows:
Get-WmiObject –Class Win32_LogicalDisk –Filter "drivetype=3" |
C:\Where-LowFreeSpace –percentfreeSpace 20
Look at that carefully so that you’re sure you understand what it’s doing. The Param() block defines the $PercentFreeSpace parameter, which was set to 20 when the script was run earlier. The Get-WmiObject command produced an unknown number of disk objects, all of which were piped into your script. The BEGIN block ran first but contained no commands.
The PROCESS block then ran with the first disk stored in the $_ placeholder. Let’s say it had 758,372,928 bytes total size and 4,647,383 bytes of free space. So that’s 4,647,383/758,372,928, or 0.0061. Multiplied times 100, that’s 0.61, meaning you have 0.61% free space. That’s not much. It’s less than 20, so the If construct will use Write-Output to write that same disk out to the pipeline. This is truly a filtering script in the purest sense of the word, because it’s applying some criteria to determine what stays in the pipeline and what gets filtered out.
You can do a lot more with these kinds of scripts than removing data from the pipeline. For example, imagine piping in a bunch of new usernames and having the script create accounts, set up home directories, enable mailboxes, and much more. We’re going to save a more complex example, because once you see the more advanced possibilities, it’s more likely that you’ll want to use them within a function.
20.7. Moving to a function
There are a lot of benefits to moving your script code into a function, and doing so is easy. Take a look at this next listing, in which the script from listing 20.4 is converted into a function.
Listing 20.5. Creating a function in Tools.ps1
Function Where-LowFreeSpace {
Param(
[int]$FreeSpace
)
BEGIN {}
PROCESS {
If ((100 * ($_.FreeSpace / $_.Size) –lt $FreeSpace)) {
Write-Output $_
}
}
END {}
}
Listing 20.5 wraps the contents of the preceding script with a Function keyword, defines a name for the function (this listing reuses the same name it had when it was just a script), and encloses the contents of the script in braces. Presto, a function!
If you run the script—which is called Tools.ps1—some neat things happen:
· PowerShell sees the function declaration and loads the function into memory for later use. It creates an entry in the Function: drive that lets it remember the function’s name, parameters, and contents.
· When you try to use the function, PowerShell will tab-complete its name and parameter names for you.
· You can define multiple functions inside the Tools.ps1 script, and PowerShell will remember them all.
But there’s a downside. Because the function lives within a script, you have to remember what we said at the outset of this chapter about scripts: They have their own scope. Anything defined in a script goes away after the script is done. Because the only thing in your script is that function, running the script simply defines that function—and then the script is completed, so the function definition is erased. The net effect is zero.
There’s a trick to resolve this, called dot sourcing. It looks like this:
PS C:>. C:\Tools.ps1
By starting the command with a period, then a space, then the path and name of your script, you run the script without creating a new scope. Anything created inside the script will continue to exist after the script is done. Now, the entry for Where-LowFreeSpace still exists in the shell’s Function: drive, meaning that you’ve basically added your own command to the shell! It’ll go away when you close the shell, of course, but you can always re–dot-source the script to get it back. Once the function is loaded, you can use it like any other PowerShell command:
Get-WmiObject –Class Win32_LogicalDisk –Filter "drivetype=3" |
Where-Lowfreespace -FreeSpace 20
You could also dot-source a script inside another script, for example:
1. Run ScriptA.ps1 normally, which creates a scope around it, so whatever it defines will go away when the script completes.
2. Inside ScriptA, dot-source Tools.ps1. Tools doesn’t get its own scope; instead, anything it creates will be defined in the calling scope—that is, inside ScriptA. So it will look as if everything in Tools.ps1 had simply been copied and pasted into ScriptA.ps1.
3. ScriptA.ps1 can now use any function from Tools.ps1.
4. When ScriptA.ps1 completes, its scope is cleaned up, including everything that was dot-sourced from Tools.ps1.
This is a handy trick. It’s a lot like the “include” capabilities that programming languages have. But in the end, it’s a bit of a hack. The bottom line is that we don’t do this very often, because there are better, more manageable, and less confusing ways to accomplish everything we’ve done here. We’re working up to that method, and you’ll see it in chapter 25 when we discuss script modules.
20.8. Filter construct
The Filter construct was introduced in PowerShell v1. A Filter is best regarded as a specialized function. It was useful in those days because it enabled you to write a function-like construct that could be used on the pipeline. A standard function, unlike a filter, blocks the pipeline until it has completed.
The introduction of advanced functions in PowerShell v2 gives you a much better approach (see chapter 24). You won’t see much use of Filter these days, but just in case you need it this is how it works.
There are two things you need to remember about Filters:
1. They’re designed to run once for each object on the pipeline
2. A Filter looks like a Function but all the statements are in a PROCESS block. You can’t use BEGIN or END blocks in a Filter.
As a demonstration, we’ll take the Where-LowFreeSpace function and turn it into a Filter (listing 20.6).
Listing 20.6. Creating a filter
Filter Where-LowFreeSpace {
Param(
[int]$FreeSpace
)
If ((100 * ($_.FreeSpace / $_.Size) –lt $FreeSpace)) {
Write-Output $_
}
}
Get-WmiObject -Class Win32_LogicalDisk | Where-LowFreeSpace -FreeSpace 75
The code inside the Filter construct runs once for each object on the pipeline; it’s a PROCESS block, as previously stated. You can use the PROCESS{} syntax if you want, but it’s not necessary.
You could write this code as:
$FreeSpace = 75
Get-WmiObject -Class Win32_LogicalDisk |
foreach {
If ((100 * ($_.FreeSpace / $_.Size) –lt $FreeSpace)) {
Write-Output $_
}
}
The code you put into a Foreach-Object PROCESS block is in effect an anonymous Filter. There’s a small amount of information, plus another example, in the about_ Functions help file. We recommend you use advanced functions rather than filters.
20.9. Summary
Scripts and functions are the basis for creating complex, repeatable automations in your environment. In this chapter, we’ve touched on the basics—but almost everything we’ve shown you here needs some refinement. We’ll continue doing that over the next few chapters so that you can build scripts and functions that are truly consistent with PowerShell’s overall philosophy and operational techniques.
As a reference, we’ll repeat our Scripting Output Rules:
· Rule #1: If you run a command and don’t capture its output into a variable, then the output of that command goes into the pipeline and becomes the output of the script.
· Rule #2: Whatever you put into the pipeline by using Write-Output will also become the output of your script.
· Rule #3: Output one kind of object, and one kind only.
Remember these three rules when you’re creating your scripts and you’ll minimize problems with your output.