So far, you could’ve accomplished everything in this book by using PowerShell’s command-line interface. You haven’t had to write a single script. That’s a big deal for us, because we see a lot of administrators initially shying away from scripting, perceiving it (rightly) as a kind of programming, and feeling (correctly) that learning it can sometimes take more time than it’s worth. Hopefully, you’ve seen how much you can accomplish in PowerShell without having to become a programmer.
But at this point, you may also be starting to feel that constantly retyping the same commands is going to become pretty tedious. You’re right, so in this chapter we’re going to dive into PowerShell scripting—but we’re still not going to be programming. Instead, we’re going to focus on scripts as little more than a way of saving our fingers from unnecessary retyping.
Most system administrators have, at one point or another, created a command-line batch file (which usually has a .bat, .cmd, or .sh filename extension). These are nothing more than simple text files (that you can edit with a text editor, such as vi) containing a list of commands to be executed in a specific order. Technically, you call those commands a script, because like a Hollywood script, they tell the performer (your computer) exactly what to do and say, and in what order to do and say it. But batch files rarely look like programming, in part because the cmd.exe shell has a limited language that doesn’t permit incredibly complicated scripts.
PowerShell scripts work similarly to Bash or sh scripts. List the commands that you want run, and the shell will execute those commands in the order specified. You can create a script by copying a command from the host window and pasting it into a text editor. We expect you’ll be happier writing scripts with the VS Code PowerShell extension, or with a third-party editor of your choice.
VS Code, in fact, makes scripting practically indistinguishable from using the shell interactively. When using VS Code, you type the command or commands you want to run, and then click the Run button in the toolbar to execute those commands. Click Save, and you’ve created a script without having to copy and paste anything at all.
Heads UP Just a reminder that this chapter is very Windows focused as far as the examples are concerned.
The idea behind PowerShell scripts is, first and foremost, to make it easier to run a given command over and over, without having to manually retype it every time. That being the case, we need to come up with a command that you’ll want to run over and over again, and use that as an example throughout this chapter. We want to make this decently complex, so we’ll start with something from CIM and add in some filtering, sorting, and other stuff.
At this point, we’re going to switch to using VS Code instead of the normal console window, because VS Code will make it easier for us to migrate our command into a script. Frankly, VS Code makes it easier to type complex commands, because you get a full-screen editor instead of working on a single line within the console host. Here’s our command:
Get-CimInstance -class Win32_LogicalDisk -computername localhost ` -filter "drivetype=3" | Sort-Object -property DeviceID | Format-Table -property DeviceID, @{label='FreeSpace(MB)';expression={$_.FreeSpace / 1MB -as [int]}}, @{label='Size(GB)';expression={$_.Size / 1GB -as [int]}}, @{label='%Free';expression={$_.FreeSpace / $_.Size * 100 -as [int]}}
Tip Remember, you can use name
instead of label
, and either can be abbreviated to a single character, n
or l
. But it’s easy for a lowercase L to look like the number 1, so be careful!
Figure 19.1 shows how we enter this into VS Code. Notice that we select the two-pane layout by using the toolbar button on the far right of the layout choices. Also notice that we format our command so that each physical line ends in either a pipe character or a comma. By doing so, we’re forcing the shell to recognize these multiple lines as a single, one-line command. You could do the same thing in the console host, but this formatting is especially effective because it makes the command a lot easier to read. Also notice that we use full cmdlet names and parameter names and that we specify every parameter name rather than using positional parameters. All of that will make our script easier to read and follow, either for someone else or in the future when we might have forgotten our original intent.
We run the command by clicking the Run toolbar icon (you could also press F5) to test it, and our output shows that it’s working perfectly. Here’s a neat trick in VS Code: you can highlight a portion of your command and press F8 to run just the highlighted portion. Because we’ve formatted the command so that there’s one distinct command per physical line, that makes it easy for us to test our command bit by bit. We could highlight and run the first line independently. If we were satisfied with the output, we could highlight the first and second lines and run them. If that worked as expected, we could run the whole command.
At this point, we can save the command—and we can start calling it a script now. We’ll save it as Get-DiskInventory.ps1. We like giving scripts cmdlet-style verb-noun names. You can see how this script is starting to look and work a lot like a cmdlet, so it makes sense to give it a cmdlet-style name.
When you think about running a command over and over, you might realize that some portion of the command will have to change from time to time. For example, suppose you want to give Get-DiskInventory.ps1 to some of your colleagues, who might be less experienced in using PowerShell. It’s a complex, hard-to-type command, and they might appreciate having it bundled into an easier-to-run script. But, as written, the script runs only against the local computer. You can certainly imagine that some of your colleagues might want to get a disk inventory from one or more remote computers instead.
One option is to have them open the script and change the -computername
parameter’s value. But it’s entirely possible that they wouldn’t be comfortable doing so, and there’s a chance they’ll change something else and break the script entirely. It would be better to provide a formal way for them to pass in a different computer name (or a set of names). At this stage, you need to identify the things that might need to change when the command is run, and replace those things with variables.
We’ll set the computer name variable to a static value for now so that we can still test the script. Here’s our revised script.
$computername = 'localhost ❶ Get-CimInstance -class Win32_LogicalDisk ` ❷ -computername $computername ` ❸ -filter "drivetype=3" | Sort-Object -property DeviceID | Format-Table -property DeviceID, @{label='FreeSpace(MB)';expression={$_.FreeSpace / 1MB -as [int]}}, @{label='Size(GB)';expression={$_.Size / 1GB -as [int]}}, @{label='%Free';expression={$_.FreeSpace / $_.Size * 100 -as [int]}}
❷ Breaks the line with a backtick
We do three things here, two of which are functional and one of which is purely cosmetic:
We add a variable, $computername
, and set it equal to localhost
. We’ve noticed that most PowerShell commands that accept a computer name use the parameter name -computername
, and we want to duplicate that convention, which is why we chose the variable name that we did.
We replace the value for the -computername
parameter with our variable. Right now, the script should run exactly the same as it did before (and we tested to make sure it does), because we put localhost
into the $computername
variable.
We add a backtick after the -computername
parameter and its value. This escapes, or takes away, the special meaning of the carriage return at the end of the line. That tells PowerShell that the next physical line is part of this same command. You don’t need to do that when the line ends in a pipe character or a comma, but in order to fit the code within this book, we needed to break the line before the pipe character. This will work only if the backtick character is the last thing on the line!
$filePath = '/usr/bin/' ❶ get-childitem -path $filepath | get-filehash | ❷ Sort-Object hash | Select-Object -first 10
❷ Breaks the line after a pipe and uses a variable
We do three things here, two of which are functional and one of which is purely cosmetic:
We add a variable, $filepath
, and set it equal to /usr/bin
. We’ve noticed that the Get-ChildItem
command accepts a path parameter name -path
, and we want to duplicate that convention, which is why we chose the variable name that we did.
We replace the value for the -path
parameter with our variable. Right now, the script should run exactly the same as it did before (and we tested to make sure it does), because we left the path
parameter blank, and it runs in the current working directory.
If you need to break up your command into multiple lines, the best way to do this is to put a line break after the pipe symbol. PowerShell knows that if there is nothing next to the pipe symbol, then the next line of code will be a continuation of the previous line. This can be very helpful if you have a very long pipeline.
Get-Process | Sort-Object ❶ Get-Process | ❷ Sort-Object Get-Process ` ❸ | Sort-Object Starting the line with a pipeline symbol
❷ Breaks the command at a pipe
❸ Breaks the command with a backtick
TIP After you make any changes, run your script to validate it is still working. We always do that after making any kind of change to ensure we haven’t introduced a random typo or other error.
Now that we’ve identified the elements of the script that might change from time to time, we need to provide a way for someone else to specify new values for those elements. We need to take that hardcoded $computername
variable and turn it into an input parameter. PowerShell makes this easy.
param ( $computername = 'localhost' ❶ ) Get-CimInstance -class Win32_LogicalDisk -computername $computername ` -filter "drivetype=3" | Sort-Object -property DeviceID | Format-Table -property DeviceID, @{label='FreeSpace(MB)';expression={$_.FreeSpace / 1MB -as [int]}}, @{label='Size(GB';expression={$_.Size / 1GB -as [int]}}, @{label='%Free';expression={$_.FreeSpace / $_.Size * 100 -as [int]}}
All we did was add a Param()
block around our variable declaration. This defines $computername
as a parameter and specifies that localhost
is the default value to be used if the script is run without a computer name being specified. You don’t have to provide a default value, but we like to do so when there’s a reasonable value that we can think of.
All parameters declared in this fashion are both named and positional, meaning that we can now run the script from the command line in any of these ways:
PS C:> .Get-DiskInventory.ps1 SRV-02 PS C:> .Get-DiskInventory.ps1 -computername SRV02 PS C:> .Get-DiskInventory.ps1 -comp SRV02
In the first instance, we use the parameter positionally, providing a value but not the parameter name. In the second and third instances, we specify the parameter name, but in the third instance we abbreviate that name in keeping with PowerShell’s normal rules for parameter name abbreviation. Note that in all three cases, we have to specify a path (.
, which is the current folder) to the script, because the shell won’t automatically search the current directory to find the script.
You can specify as many parameters as you need to by separating them with commas. For example, suppose that we want to also parameterize the filter criteria. Right now, it’s retrieving only logical disks of type 3, which represents fixed disks. We could change that to a parameter, as in the following listing.
param ( $computername = 'localhost', $drivetype = 3 ❶ ) Get-CimInstance -class Win32_LogicalDisk -computername $computername ` -filter "drivetype=$drivetype" | ❷ Sort-Object -property DeviceID | Format-Table -property DeviceID, @{label='FreeSpace(MB)';expression={$_.FreeSpace / 1MB -as [int]}}, @{label='Size(GB';expression={$_.Size / 1GB -as [int]}}, @{label='%Free';expression={$_.FreeSpace / $_.Size * 100 -as [int]}}
❶ Specifies an additional parameter
Notice that we take advantage of PowerShell’s ability to replace variables with their values inside double quotation marks (you learned about that trick in chapter 16). We can run this script in any of the three original ways, although we could also omit either parameter if we wanted to use the default value for it. Here are some permutations:
PS C:> .Get-DiskInventory.ps1 SRV1 3 PS C:> .Get-DiskInventory.ps1 -ComputerName SRV1 -drive 3 PS C:> .Get-DiskInventory.ps1 SRV1 PS C:> .Get-DiskInventory.ps1 -drive 3
In the first instance, we specify both parameters positionally, in the order in which they’re declared within the Param()
block. In the second case, we specify abbreviated parameter names for both. The third time, we omit -drivetype
entirely, using the default value of 3
. In the last instance, we leave off -computername
, using the default value of localhost
.
Only a truly mean person would create a useful script and not tell anyone how to use it. Fortunately, PowerShell makes it easy to add help into your script, using comments. You’re welcome to add typical programming-style comments to your scripts, but if you’re using full cmdlet and parameter names, sometimes your script’s operation will be obvious. By using a special comment syntax, however, you can provide help that mimics PowerShell’s own help files. This listing shows what we’ve added to our script.
<# .SYNOPSIS Get-DiskInventory retrieves logical disk information from one or more computers. .DESCRIPTION Get-DiskInventory uses CIM to retrieve the Win32_LogicalDisk instances from one or more computers. It displays each disk's drive letter, free space, total size, and percentage of free space. .PARAMETER computername The computer name, or names, to query. Default: Localhost. .PARAMETER drivetype The drive type to query. See Win32_LogicalDisk documentation for values. 3 is a fixed disk, and is the default. .EXAMPLE Get-DiskInventory -computername SRV02 -drivetype 3 #> param ( $computername = 'localhost', $drivetype = 3 ) Get-CimInstance -class Win32_LogicalDisk -computername $computername ` -filter "drivetype=$drivetype" | Sort-Object -property DeviceID | Format-Table -property DeviceID, @{label='FreeSpace(MB)';expression={$_.FreeSpace / 1MB -as [int]}}, @{label='Size(GB';expression={$_.Size / 1GB -as [int]}}, @{label='%Free';expression={$_.FreeSpace / $_.Size * 100 -as [int]}}
PowerShell ignores anything on a line that follows a #
symbol, meaning that #
designates a line as a comment. We can also use the <#
#>
block comment syntax instead, because we have several lines of comments and want to avoid starting each line with a separate #
character.
Now we can drop to the normal console host and ask for help by running help
.Get-DiskInventory.ps1
(again, you have to provide a path because this is a script and not a built-in cmdlet). Figure 19.2 shows the results, which proves that PowerShell is reading those comments and creating a standard help display.
We can even run help .Get-DiskInventory
-full
to get full help, including parameter information in our example.
These special comments are called comment-based help. There are several keywords in addition to .DESCRIPTION
, .SYNOPSIS
, and the others we’ve used. For a full list, run help about_comment_based_help
in PowerShell.
We normally tell folks that anything in a script will run exactly as if you manually typed it into the shell, or if you copied the script to the clipboard and pasted it into the shell. That’s not entirely true, though. Consider this simple script:
Just two commands. But what happens if you were to type those commands into the shell manually, pressing Enter after each?
Try it Now Run these commands on your own to see the results. They create fairly long output that won’t fit well within this book or even in a screenshot.
When you run the commands individually, you’re creating a new pipeline for each command. At the end of each pipeline, PowerShell looks to see what needs to be formatted and creates the tables that you undoubtedly saw. The key here is that each command runs in a separate pipeline. Figure 19.3 illustrates this: two completely separate commands, two individual pipelines, two formatting processes, and two different-looking sets of results.
You may think we’re crazy for taking so much time to explain something that probably seems obvious, but it’s important. Here’s what happens when you run those two commands individually:
The pipeline ends in Out-Default
, which picks up the objects.
Out-Default
passes the objects to Out-Host
, which calls on the formatting system to produce text output (you learned about this in chapter 11).
The pipeline ends in Out-Default
, which picks up the objects.
Out-Default
passes the objects to Out-Host
, which calls on the formatting system to produce text output.
So you’re now looking at a screen that contains the results from two commands. We want you to put those two commands into a script file. Name it Test.ps1 or something simple. Before you run the script, though, copy those two commands onto the clipboard. In your editor, you can highlight both lines of text and press Ctrl-C to get them onto the clipboard.
With those commands on the clipboard, go to the PowerShell console host and press Enter. That pastes the commands from the clipboard into the shell. They should execute exactly the same way, because the carriage returns also get pasted. Once again, you’re running two distinct commands in two separate pipelines.
Now go back to your editor and run the script. Different results, right? Why is that?
In PowerShell, every command executes within a single pipeline, and that includes scripts. Within a script, any command that produces pipeline output will be writing to a single pipeline: the one that the script itself is running in. Take a look at figure 19.4.
We’ll try to explain what happens:
The pipeline ends in Out-Default
, which picks up both kinds of objects.
Out-Default
passes the objects to Out-Host
, which calls on the formatting system to produce text output.
Because the Process
objects are first, the shell’s formatting system selects a format appropriate to processes. That’s why they look normal. But then the shell runs into the TimeSpan
objects. It can’t produce a whole new table at this point, so it winds up producing a list.
This different output occurs because the script writes two kinds of objects to a single pipeline. This is the important difference between putting commands into a script and running them manually: within a script, you have only one pipeline to work with. Normally, your scripts should strive to output only one kind of object so that PowerShell can produce sensible text output.
The last topic we need to visit is scope. Scopes are a form of container for certain types of PowerShell elements, primarily aliases, variables, and functions.
The shell itself is the top-level scope and is called the global scope. When you run a script, a new scope is created around that script, and it’s called the script scope. The script scope is a subsidiary—or a child—of the global scope. Functions also get their own private scope, which we will cover later in the book.
Figure 19.5 illustrates these scope relationships, with the global scope containing its children, and those containing their own children, and so forth.
A scope lasts only as long as needed to execute whatever is in the scope. The global scope exists only while PowerShell is running, a script scope exists only while that script is running, and so forth. When whatever it is stops running, the scope vanishes, taking everything inside with it. PowerShell has specific—and sometimes confusing—rules for scoped elements, such as aliases, variables, and functions, but the main rule is this: If you try to access a scoped element, PowerShell sees whether it exists within the current scope. If it doesn’t, PowerShell sees whether it exists in the current scope’s parent. It continues going up the relationship tree until it gets to the global scope.
TIP To get the proper results, it’s important that you follow these steps carefully and precisely.
Let’s see this in action. Follow these steps:
Close any PowerShell or PowerShell editor windows you may have open so that you can start from scratch.
In VS Code, create a script that contains one line: Write
$x
.
In the regular PowerShell window, run the script with C:Scope.ps1. You shouldn’t see any output. When the script runs, a new scope is created for it. The $x
variable doesn’t exist in that scope, so PowerShell goes to the parent scope—the global scope—to see whether $x
exists there. It doesn’t exist there, either, so PowerShell decides that $x
is empty and writes that (meaning, nothing) as the output.
In the normal PowerShell window, run $x
=
4
. Then run C:Scope.ps1 again. This time, you should see 4
as the output. The variable $x
still isn’t defined in the script scope, but PowerShell is able to find it in the global scope, so the script uses that value.
In VS Code, add $x
=
10
to the top of the script (before the existing Write
command), and save the script.
In the normal PowerShell window, run C:Scope.ps1 again. This time, you’ll see 10
as the output. That’s because $x
is defined within the script scope, and the shell doesn’t need to look in the global scope. Now run $x
in the shell. You’ll see 4
, proving that the value of $x
within the script scope doesn’t affect the value of $x
within the global scope.
One important concept here is that when a scope defines a variable, alias, or function, that scope loses access to any variables, aliases, or functions having the same name in a parent scope. The locally defined element will always be the one PowerShell uses. For example, if you put New-Alias Dir
Get-Service
into a script, then within that script the alias Dir
will run Get-Service
instead of the usual Get-ChildItem
. (In reality, the shell probably won’t let you do that, because it protects the built-in aliases from being redefined.) By defining the alias within the script’s scope, you prevent the shell from going to the parent scope and finding the normal, default Dir
. Of course, the script’s redefinition of Dir
will last only for the execution of that script, and the default Dir
defined in the global scope will remain unaffected.
It’s easy to let this scope stuff confuse you. You can avoid confusion by never relying on anything that’s in any scope other than the current one. So before you try to access a variable within a script, make sure you’ve already assigned it a value within that same scope. Parameters in a Param()
block are one way to do that, and there are many other ways to put values and objects into a variable.
Note For this lab, you need any computer running Windows 10 or Server 2019 with PowerShell v7 or later.
The following command is for you to add to a script. You should first identify any elements that should be parameterized, such as the computer name. Your final script should define the parameter, and you should create comment-based help within the script. Run your script to test it, and use the Help
command to make sure your comment-based help works properly. Don’t forget to read the help files referenced within this chapter for more information. Here’s the command:
Get-CimInstance -classname Win32_LogicalDisk -filter "drivetype=3" | Where { ($_.FreeSpace / $_.Size) -lt .1 } | Select -Property DeviceID,FreeSpace,Size
Here’s a hint: At least two pieces of information need to be parameterized. This command is intended to list all drives that have less than a given amount of free disk space. Obviously, you won’t always want to target localhost
(we’re using the PowerShell equivalent of %computername%
in our example), and you might not want 10% (that is, .1) to be your free-space threshold. You could also choose to parameterize the drive type (which is 3 here), but for this lab, leave that hardcoded with the value 3
.
<# .Synopsis Get drives based on percentage free space .Description This command will get all local drives that have less than the specified ➥ percentage of free space available. .Parameter Computername The name of the computer to check. The default is localhost. .Parameter MinimumPercentFree The minimum percent free diskspace. This is the threshold. The default value ➥ is 10. Enter a number between 1 and 100. .Example PS C:> Get-DiskSize -minimum 20 Find all disks on the local computer with less than 20% free space. .Example PS C:> Get-DiskSize -Computername SRV02 -minimum 25 Find all local disks on SRV02 with less than 25% free space. #> Param ( $Computername = 'localhost', $MinimumPercentFree = 10 ) #Convert minimum percent free $minpercent = $MinimumPercentFree / 100 Get-CimInstance -classname Win32_LogicalDisk –computername $computername ` -filter "drivetype=3" | Where { $_.FreeSpace / $_.Size –lt $minpercent } | Select –Property DeviceID, FreeSpace, Size
3.22.61.179