Thursday, November 6, 2008

Like VIM? Like PowerShell? This link's for you!

Vim is a text editor that is popular with *nix users, but it works just fine on Windows, too.  

I subscribe to Andy Schneider's Get-PowerShell blog, and today he had an entry about a new extension for Vim that does your syntax highlighting, etc, for PowerShell.  I recommend checking out the Get-PowerShell blog, but if you just want the file, follow the link from Peter Provost's blog.

Tuesday, November 4, 2008

Did I Mention PrimalForms is free?

Here's that link again.

Oh, and for some reason this post shows up under the Google search , but click here if you want a PrimalForms example.

Windows Forms with PowerShell

SAPIEN came out with an awesome form builder for PowerShell called PrimalForms.  I decided to give it a whirl.  It really makes short work of making a native Windows GUI.  For my first project I made a quick and dirty app that allows me to get disk fragmentation data for a remote server using the DefragAnalysis method of Win32_Volume.  I used the PrimalForms GUI to create my form, and then I only had to fill in the button1 click handler and the form load handler, add a small function for printing to the text box, and voila!






Friday, October 31, 2008

Defrag Your Servers Remotely with PowerShell

Another quick and dirty script.  I've got a couple servers that are running applications that suffer from disk thrashing, and are running Windows Server 2003.  A defrag tends to noticeably increase performance.  With this script I can kick off a defrag from my desk whenever I want.






Friday, October 3, 2008

Get-SmsWmi and Find-SmsID

I work with SMS (Systems Management Server) at work a lot, so I whipped up two functions that I've found invaluable for automating tasks in SMS, which I alluded to briefly in this post, which contains the source for the functions.

I thoght I'd post today about how these can be used and why they would be useful.  Think of it as the beginnings of a Get-SmsWmi cookbook.

Note:  I alias Get-SmsWmi to 'gsms', so keep that in mind for the examples.

Get a list of all available object nicknames.

C:\PSScripts> gsms

ERROR: You must enter a class name or nickname.

Valid nicknames are:

  AddRemovePrograms
  AdStatus
  Advertisement
  Collection
  ComputerSystem
  DistributionPoint
  LogicalDisk
  MembershipRule
  NetworkAdapter
  NetworkAdapterConfiguration
  OperatingSystem
  Package
  PackageStatus
  Program
  Query
  Server
  Service
  Site
  StatusMessage
  System
  WorkstationStatus
  User

Note: You only need to type as many characters as necessary to be unambiguous.


Get all computers with 'tojo' in the name.

gsms sys 'Name LIKE "%tojo%"' |
  Format-List ResourceId, 
  Name, 
  LastLogonUserName, 
  LastLogonUserDomain, 
  IPAddresses


ResourceId          : 220647
Name                : SOMETOJO-CORP
LastLogonUserName   : sometojo
LastLogonUserDomain : MYDOMAIN
IPAddresses         : {1.1.1.1}


Get all advertisements for the package with PackageID 'S01002BE'

gsms adv 'PackageId = "S00002BE"' |
  select AdvertisementID,
  AdvertisementName,
  @{Name = 'PackageName'; Expression = {(Find-SmsId -p 'S00002BE').Name}},
  PackageID,
  ProgramName |
    Format-List

AdvertisementID   : MV12071F
AdvertisementName : iPass Upgrade
PackageName       : iPass Upgrade
PackageID         : S00002BE
ProgramName       : Update iPass


Delete all packages for a particular distribution point.

gsms pack 'ServerNALPath LIKE "%servername%"' | % {$_.Delete()}


Get a directory listing of all Collections and their subcollections.

# Get-SmsCollectionTree
#
# Writes an indented list of collecitons by parent
#
# Args:
#   $root: The CollectionID of the parent collection
#   $indent: The indentation level for screen output

function Get-SmsCollectionTree {
  param([string]$root = 'COLLROOT',
        [int]$indent = 0)

  Get-SmsWmi SMS_CollectToSubCollect "parentCollectionID = '$root'" |
    % {$name =  (Find-SmsID -c $_.subCollectionID).Name
       Add-Member -InputObject $_ -Name 'sub_name' NoteProperty $name
       $_} |
      sort sub_name |
        % {Write-Host (('    ' * $indent) +
                        "+ $($_.sub_name) : $($_.subCollectionID)")
           Get-CollectionTree $_.subCollectionID ($indent + 1)}
}

Add all packages to a DP.

$dp = gsms SMS_SystemResourceList 'ServerName = "sms-dp-01" and RoleName = "SMS Distribution Point"'

foreach ($pkg in (gsms package)) {
  $new_object = ([wmiclass]"\\sms_srv\root\sms\site_MV1:SMS_DistributionPoint").CreateInstance()
  $new_object.ServerNALPath = $server.NALPath
  $new_object.PackageID = $pkg.PackageID
  $new_object.SiteCode = 'S00'
  $new_object.SiteName = 'My SMS Site'
  $new_object.Put()
}

Note:  That wmiclass/CreateInstance trick I did isn't very well documented, but if you need to create an instance of a class then that's how you can do it.  Also remember that when reading and writing to WMI for SMS, you should be connecting to the server with the SMS Provider, not necessarily the site server.  If you have a separate SQL Server then it might be that server.

That's it for today, but you can see how easy it is to start grabbing data directly from WMI and using it in a script.  If you have any questions drop them in the comments or shoot me an email, I don't get that much traffic.

For a complete list of the WMI classes, download the Configuration Manager 2007 SDK.

Thursday, September 25, 2008

Pipes, Loops, and Exploding Memory Usage

Background

Recently I wrote a script to automate some reports that are a huge pain.  I was pretty pleased with myself when I finished, but when I ran it, it kept going...and going...  It was taking a really long time, which might not have been strange because there was a lot of data, but I popped up my Task Manager, and that's when I noticed that powershell.exe was using up 1GB of RAM and climbing.  Clearly I had a problem with the design of the script, but what shocked me was that I was able to fix this  by replacing a foreach loop with a pipe to foreach-object, and the end result was that my powershell.exe process never uses more than 55MB of RAM.


Passing Objects Down the Pipe

One of the cool things about pipes is that as data is generated by a cmdlet or function it is passed down the pipe to the next one without having to wait for all of the data finish being generated. 

Consider the following:

C:\PowerShell> dir c:\ -include *.log -recurse | % {$_.FullName}

As each file is found that matches the pattern, it will be returned.  Now let's try it with a foreach loop:

foreach ($file in (dir c:\ -include *.log -recurse)) {
  $_.FullName
}

This time we have to wait for the entire hard drive to be scanned before the output comes out, and we'll use a lot more memory.  Why?   Because when you use parentheses, the expression between them is evaluated BEFORE the loop is processed.  This is essentially the same as the following:

$files = dir c:\ -include *.log -recurse

foreach ($file in $files) {
  $_.FullName
}

Most things you use PowerShell for probably won't be so large that this becomes a huge issue.  In my case I was querying Systems Management Server for inventory information on tens of thousands of computers, so it really started to impact the other things I was using.


Planning Ahead

As you're creating your scripts, try to be conscious of where you're using piped commands vs. loops, and consider how it would change your script if you refactored the code to do it a different way.  I tend to use loops more when I'm writing scripts because they are generally more readable and easier to update for the next poor sap who has to edit my code, but it's important no matter which way you choose to get the job done that you try to understand the flow of execution of your script.  

Some questions I try to ask myself when I think I'm done with my scripts:
  • Where am I causing my script to stop and collect all of the input pipeline before continuing?  (sorting in the middle of the pipeline is the classic example of this)  Does it matter?
  • What variables am I declaring at the top level that can be moved so that they are deleted automatically when they leave scope?
  • What is the impact on readability?

Sunday, September 21, 2008

I'm Not Dead

I've been really busy at work but keep an eye out for a real post very soon. If you want to check out some PowerShell scripts head on over to http://poshcode.org, where you can find PowerShell scripts submitted by users.

There has been talk in the community about trying to come up with a CPAN for PowerShell.  This is not it, but it's a start.  For one thing, CPAN grew enough that they could enforce readability and technique requirements by having someone actually review each module.  PowerShell is still too young for that.

I decided to post my SMS.psm1 module for making command-line managment of SMS easier, so here goes nothing.  I'll do a post on how to use it later.






Note:  You need PowerShell v2 CTP2 in order to use this.   Copy it into %userprofile%\Documents\WindowsPowerShell\Packages\SMS\.

Thursday, September 4, 2008

PowerShell Team Blog: Text Output Is Not a Contract


I just wanted to bring people's attention to this post from Jeffrey Snover, whose feet I am not worthy to wash with my perfume-soaked hair**.

It just reiterates the new mindset that Perlers and Pythonistas have to keep in mind when either transitioning to or assimilating with PowerShell topics.



** I know, too much information.

Monday, September 1, 2008

Putting the Fun in Functions

I just wanted to make a quick post to point out two neat features of functions that I left out of the last post since it was getting a little long, piping to functions, and autocomplete for functions.


AutoComplete for Cmdlets and Functions

You're probably used to using the TAB key to autocomplete file names, but have you noticed that you can autocomplete cmdlet and function names, too?  This comes in useful a lot since I don't have all of the standard cmdlets' names memorized yet.  Just start typing the name of a function or cmdlet and hit tab.  If the name that comes up isn't what you're looking for, just keep hitting TAB and you'll cycle through the available options.

For example:

PS C:\>  out-

Will give you, if you keep tabbing:
  • Out-Clipboard
  • Out-Default
  • Out-File
  • Out-GridView (super cool, I didn't know about this one)
  • Out-Host
  • Out-Null
  • Out-Printer
  • Out-String


Piping to Functions

Piping to functions is really easy.  Anything piped to a function is automatically added to an array called $input.  You can just add a loop in your function to cycle through the values in $input and voila!

Take the following example**:

# Get-Count()
# Gets the number of objects in the input pipeline.
#
# Returns:
#   An int with the count
#

function Get-Count () {
  $i = 0;

  foreach ($obj in $input) {
    $i++;
  }

  Write-Output $i
}


** "But Tojo," you're thinking, "Doesn't Measure-Object do the same thing?"  Indeed it does, but it's much slower in my experience because it also has a lot of extra bells and whistles that I don't need if I just want to see how many lines are in a file, etc.

Friday, August 29, 2008

Functions in PowerShell


What's a function?

A function is a script block with a name.  Most functions also take parameters and/or return a value, but that's not required.  The PSDrive called function: has a list of all defined functions, and there will be quite a few in there from the first time you install PowerShell.  You can use Get-Item (or gi, for short) cmdlet to get the Definition property of the function to see how they're made, as shown below.

Function Parameters and the param() Method

Let's make a test function and try this bad boy out.  I'm going to make a function that adds two numbers.  You can use the return statement to explicitly return a value, but any expression that is not assigned to a variable will be sent as output.

function Add-Numbers ($x, $y) {
  $x + $y
}

PS Function:\>  Add-Numbers 23 77
100

Okay, that worked, so let's look at the Definition property:

Function:\>  (gi Add-Numbers).Definition
param($x, $y) $x + $y

That's funny, I don't remember using the param() method in my function.

The param() method is implied when you put your parameters in parentheses before the script block.  This code would do the same thing:

function Add-Numbers {
  param($x, $y)
  $x + $y
}

Command-Line Switches and Named Parameters

So how do we set up named parameters?  We already did.  In PowerShell every variable name that you add to the param() method also is automatically created as a named parameter that you can use at the command-line.  Observe:

Function:\>  Add-Numbers -y 10 -x 20
30

Default Parameter Values

What if you have an optional variable?  Something that usually has the same value, that you'd rather not have to type in every time?   Easy.  Just assign it a value within the param method and you're done.  If you use the parameter when you call the function, then it will take on the value you assign, but if you don't, then it will keep the default value.

function Add-Numbers {
  param($x, $y=20)
  $x + $y
}

Function:\>  Add-Numbers 203
223

A Final Note about Parameters

There are two caveats when calling a function that you should be aware of:
  1. Unnamed parameters are assumed to be in the order that they were declared in param().  If you call Add-Numbers with only one number then it will be assigned to $x because that variable came first.  
  2. Any parameters left over after being assigned to parameters get dumped into an array called $args.

An Example Incorporating What We've Learned

# Get-SmsWmi
# A wrapper for Get-WmiObject that makes it easy to get objects from SMS.
#
# Args:
#   $Class: the WMI class to retrieve
#   $Filter: the where clause of the query
#   $Computername: the SMS server hosting the SMS Provider
#   $Site: the SMS Site Code of the target site
# Returns:
#   An array of WMI objects

function Get-SmsWmi {
  param([string]$Class = $(throw "ERROR: You must enter a class name.`n"), 
        [string]$Filter = $null, 
      [string]$ComputerName = 'sms-server', 
      [string]$Site = 'S00')
  
  [string]$query = "Select * from $Class"
  
  if ($Filter) {
    $query += " Where $Filter"
  }
  
  # Now that we have our parameters, let's execute the command.
  gwmi -ComputerName $ComputerName -Namespace ('root\sms\site_' + $Site) -Query $query
}

Get All Shared Printers with WMI and PowerShell

I whipped up this handy dandy script yesterday for work, and I think it makes a good simple example script, so I'm posting it here:

# Finds all servers that are hosting printers.
#
# Author: Tojo2000 
#
# Note: Requires a text file with one server per line named 'winserv.txt'.

$credential = Get-Credential
$servers = gc winserv.txt | sort

echo "ServerName`tShareName`tName`tDriverName`tStatus" > .\printers.txt

foreach ($server in $servers) {
  echo $server
  $ping = gwmi Win32_PingStatus -Filter "address = `"$server`""

  if ((!$ping.StatusCode) -and (!$ping.PrimaryAddressResolutionStatus)) {
    $printers = gwmi -Credential $credential -ComputerName $server -class Win32_Printer -Filter 'Shared = "True"'

    foreach ($printer in $printers) {
      if ($printer) {
        $output = $server, $printer.ShareName, $printer.Name, $printer.DriverName, $printer.Status
        echo ($output | Join-String2 "`t") >> .\printers.txt
      }
    }
  }
}

__________________________________________

# Join-String2
# Args:
#   $join_string: the string to paste between each element of the string array
#
# Returns:
#   The resulting string to the pipeline

function global:Join-String2 ([string]$join_string) {
  $output = '';
  $input_array = @($input);
  
  foreach ($string in $input_array[0..($input_array.Length - 2)]) {
    $output += $string;
  $output += $join_string;
  }
  $output += $input_array[-1];
  
  Write-Output $output;
}


There are a couple of things worthy of note here.  The Get-Credential cmdlet pops up a prompt to enter a username and password and stores it as a SecureString This is vastly more secure than storing the string in your script or typing it in to the console window.  Get-WMIObject, like many cmdlets, has a -Credential option that takes a credential as an input and runs the cmdlet under that account.  You can also specify a username here, and Get-Credential will be invoked to prompt you for the password.

The Join-String2 function is because there is already a cmdlet that is part of the PowerShell Comunity Extensions called Join-String, but apparently it freaks out if any of the values being printed are null, so I created a function that does a basic join.

The -Filter option of Get-WMIObject is basically just the "where clause" of the wmi query, which is to say that

gwmi Win32_Printer -Filter 'Shared = "True"'

is the same thing as

gwmi -Query 'Select * From Win32_Printer Where Shared = "True"'

It's just a lot more PowerShell-ish and a little less verbose.

Thursday, August 21, 2008

PowerShell and COM

I mentioned to a few people today that you can use PowerShell to create COM objects, but I think that warrants a little explaining.  VBScript automation examples use COM a lot, and if you're going to want to use PowerShell to automate Word documents or any of that other neat stuff, you'll need to use it as well.  

Consider the code below:

PS C:\> $sms_client = New-Object -COM 'CPApplet.CPAppletMgr'             #1
PS C:\> $sms_client | gm                                                 #2


   TypeName: System.__ComObject#{279463bb-1034-4fb5-878e-4a330a08beab}

Name                MemberType Definition
----                ---------- ----------
GetClientActions    Method     IClientActions GetClientActions ()
GetClientComponents Method     IClientComponents GetClientComponents ()
GetClientProperties Method     IClientProperties GetClientProperties ()

PS C:\> $actions = $sms_client.GetClientActions()                        #3
PS C:\> $actions | select Name                                           #4

Name
----
Software Inventory Collection Cycle
MSI Product Source Update Cycle
Hardware Inventory Collection Cycle
Standard File Collection Cycle
Discovery Data Collection Cycle
Request & Evaluate User Policy
Request & Evaluate Machine Policy
Software Metering Usage Report Cycle

PS C:\> $machine_policy = $actions | ?{$_.Name -like "*Machine Policy"}  #5
PS C:\> $machine_policy.PerformAction()                                  #6

In the code above, "CPApplet.CPappletMgr" is the name of the COM type that is equivalent tot he Systems Management applet in the control panel, so here's what the code does:

  1. Creates a new COM object of type 'CPApplet.CPAppletMgr'.  This is the equivalent of "sms_client = CreateObject('CPApplet.CPAppletMgr')" in VBScript.
  2. Runs Get-Member (gm) on the variable I just set to see what methods are available.
  3. Calls the GetClientActions method and stores the resulting array in $actions.
  4. Uses Select-Object (select) to get just the names of each action
  5. Uses Where-Object (?) to get just the action whose name is like "*Machine Policy" and assign it to $machine_policy
  6. Invokes the PerformAction() method of $machine_policy.

Worthy of note is that piping to where technically creates a one-element array, but PowerShell automatically converts that to a scalar by default, so I didn't have to use $machine_policy[0].  If I had actually wanted it to be treated as an array (let's say I'll be using the .Length() method of an array to see how many items were returned, I would have to use the @() syntax to force it:

PS C:\> $machine_policy = @($actions | ?{$_.Name -like "*Machine Policy"}) 

Friday, August 15, 2008

Getting Network Adapter Information Using Get-WMIObject and GetRelated()

WMI stores network adapter information in two classes: Win32_NetworkAdapter and Win32_NetworkAdapterConfiguration.  The former has basic information about the network adapter, and the latter has the IP configuration, etc.

The last time I had to do this, had to look up both objects and compare them to see that the property that linked the two classes was called Index and then write this to output the Win32_NetworkAdapter object, the associated Win32_NetworkAdapterConfiguration object, and then a line of dashes to separate the adapters.

gwmi Win32_NetworkAdapterConfiguration| %{gwmi -query "Select * from Win32_NetworkAdapter Where Index = $($_.Index)"; $_;  '------------------'}

As I discoverd from another post by /\/\o\/\/ (aka The PowerShell Guy), it turns out that the objects returned by Get-WmiObject have a nifty method called GetRelated that will do that work for you, so you can do this instead:

gwmi Win32_NetworkAdapterConfiguration | %{$_.GetRelated('Win32_NetworkAdapter'); $_;  '------------------'}

Tuesday, August 12, 2008

Recursion in PowerShell

Recursion is one of those things that you'll probably find that you don't need to use all that often, but it's a really powerful concept, and where it's useful it can save you a lot of time, and not just in PowerShell.  Simply put, a recursive function has the ability to call itself, usually to perform a task with an unknown depth.

I remember the first time I hit this problem, and I had just started scripting.  I wanted to see who had permissions to a folder.  I walked the ACL on the folder and found a group, so wrote a function to grab the members of the group.  That's when I realized that some of the group's members were groups.  My first solution was horribly unwieldy.  I wrote a bunch of if/else statements that were able to grab group memberships up to three levels deep, and as I was looking unhappily at my hard-to-follow code, a spark of a memory started nagging at me, and I remembered an email thread about recursion, and that's the first time I really "got it".

Let's cut to the chase with an easy example: walking a directory tree.  The function below will use Get-ChildItem (dir) to grab all child items in the current PSPath and print their FullName.  In order to get all files in all child directories, every time it hits a directory it will call itself on the directory.  

# Recurse($path, $fileglob)
# Recurses through a psdrive and prints all items that match.
#
# Args:
#   [string]$path: The starting path
#   [string]$fileglob(optional): The search string for matching files
#
function Recurse ([string]$path, [string]$fileglob){
  if (-not (Test-Path $path)) {
    Write-Error "$path is an invalid path."
    return $false
  }

  $files = @(dir -Path $path -Include $fileglob)

  foreach ($file in $files) {
    if ($file.GetType().FullName -eq 'System.IO.FileInfo') {
      Write-Output $file.FullName
    }elseif ($file.GetType().FullName -eq 'System.IO.DirectoryInfo') {
      Recurse $file.FullName
    }
  }
}

In my mind I always picture a cat's cradle-style string looping through itself over and over until the final loop is met and a final tug unravels the whole thing back into a string.

Wednesday, August 6, 2008

Fish Don't Fry in the Kitchen

We're moving on up...to blogspot. I initially posted Taste of PowerShell to my personal domain, just because I wanted to watch the traffic, etc, but ultimately I just couldn't stand it anymore. I really like using the labels links on the side, and you can't do that if you use blogger to post your blog to another domain via FTP.


The new URL is http://tasteofpowershell.blogspot.com.

Friday, August 1, 2008

Fun with PowerShell and the Speech API

I don't know why, but listening to a computer-generated voice reading things you type is just a lot of fun, and the potential for annoying your friends, neighbors, pets, and co-workers is endless.  Personally I blame my parents for buying me a Speak 'n' Spell when I was a kid.

Anyway, to that end, I present the following little ditty, a function I like to call Speak-String (which I usually alias to "say"), inspired by a few posts using the same trick that I've come across on the Internet.

function Speak-String([string]$message) {
  $speaker = new-object -com SAPI.SpVoice
  $speaker.Speak($message, 1)
}

Set-Alias say 'Speak-String'

PS C:\> Say "Would you like to play a game?"
PS C:\> Say "How about Global Thermonuclear War?"



Formatting Strings in PowerShell

If you're a recovering Perl user, Pythonista, or C junkie, pretty soon you'll be sniffing around in PowerShell looking for a way to format strings.

Does this look familiar to you?

sprintf("%02.2f percent of %04d in %s", $percent, $from, $some_string); # Perl

"%02.2f percent of %04d of %s" % (percent, from, some_string) # Python


Never mind if it doesn't look familiar, but keep this in mind: the ability to choose the format that a variable will be printed in becomes very useful when scripting, especially when dealing with numbers.  Let me give you an example:

You do a dir on the current directory to get the size of a file, and the size is 16493253, but you want to output the number of MB,  so:

PS C:\> 16493253/1MB
15.7291917800903

Now that's a long, nasty number.  Really, you just want to show numbers to two places past the decimal.  PowerShell's way of doing it looks a bit obtuse at first, but it is actually very efficient.

PS C:\> "{0:#.00}" -f (16493253/1MB)
15.73

Note that the .NET string formatting rounds up the number when it sets the precision.  Anyway, let's look at what I just did.  According to Microsoft, Composite Formatting follows this syntax:

{ index[,alignment][:formatString]}

So for starters, any paired curly braces within the format string will be substituted with whatever strings are passed to it via the -f operator.  Consider the following example:

PS C:\> "I have a {0}.  My {0} is {1}." -f "name", "Tojo"
I have a name.  My name is Tojo.

As you can see, the "index" value inserts whatever string is in that position that is passed to the -f operator.  

If you want the string to be placed within a particular column width, you can optionally use the next value, the "alignment" value.  A positive number indicates a right-justified string at least n spaces wide, and a negative number indicates a left-justified string.

PS C:\> ":{0, 20}:" -f "Tojo"
:                Tojo:
PS C:\> ":{0, -20}:" -f "Tojo"
:Tojo                :

The last section of the Composite Format is the actual formatString value.  This is the part of the string that tells PowerShell how to format the string for output.  Let's take a look at the earlier example again:

PS C:\> "{0:#.00}" -f (.16493253/1MB)
15.73

What we have is a format that tells PowerShell that we want to replace the string between the braces with the string after the -f operator and put it into the following format: zero or more digits followed by the decimal with exactly two digits after the decimal.

I won't bother going through all of the possible variations for now, I'll just point you to some useful resources on the Internet, but there is one more thing I should mention.  For any object that supports the ToString() method, you have the option of calling ToString() and passing it the format string directly rather than using the "formatString -f Variable" syntax.

Here are some invaluable resources for when you can't remember the format you're looking for, whether it's a date, percent, currency, or whatever:

MSDN Resources

Other Internet Resources
Under The Stairs: Formatting with PowerShell -- another great writeup on formatting
Hey Scripting Guy!  -- an article on how to format a number as a phone number


Monday, July 28, 2008

Here Docs in PowerShell

Those of you that are familiar with Unix-style shell scripting or Perl may be familiar with the concept of a HERE doc.  This example from Perl may help explain the name:

my $multiline_string = <<HERE;
This
  is
    my
      string.
HERE


The idea is that once you start the Here Doc, everything you type is taken as part of your string until you tell it that it's time to stop, in this case by putting HERE on its own line.

In PowerShell you can do something similar, using @' and '@ or @" and "@.  Everything from the @' or @" to the '@ or "@ is considered part of the same string.

PS C:\Users\tojo2000\Documents> $multiline_string = @'
>> This
>>   is
>>     my
>>       System.String.
>> '@
>>
PS C:\Users\tojo2000\Documents\> $multiline_string
This
  is
    my
      System.String.

Note that the multiline strings use single and double quotes to control variable interpolation just like regular single and double quotes.  Any variables in a multiline double-quoted string will be expanded.

Why would you want to do this?  There are a lot of situations in which this can make your strings a lot more readable.  As an example, let's say you have a SQL query:

$sql = 'SELECT * FROM employees INNER JOIN parking ON parking.emp_id = employees.emp_id WHERE parking.size = "Compact"';

compare that to this:

$sql = @'
SELECT * FROM employees
INNER JOIN parking
  ON parking.emp_id = employees.emp_id
WHERE parking.size = "Compact"
'@