Viewing entries in
PowerShell

PowerShell: Calculating Folder Sizes

PowerShell: Calculating Folder Sizes

Sometimes all you want to know, or need to know, is how big a folder is in PowerShell. To do that, we'll need to use Get-ChildItem and Measure-Object specifically.

The quick and dirty

The first command we'll want to use is Get-ChildItem, and specify the base folder we'll want to look at. We'll also want to use the -Recurse switch with Get-ChildItem so we include the size of all the sub folders in the calculation as well.

We'll then need to pipe that to Measure-Object, which will allow us to look at the property Length, and calculate the Sum. This will be in bytes, which I will convert to megabytes via the string format operator (-f).  I will also use the format operator to only show 2 decimal places in the number returned. 

The one liner

"{0} MB" -f ((Get-ChildItem C:\users\ -Recurse | Measure-Object -Property Length -Sum -ErrorAction Stop).Sum / 1MB)

This command returns the folder size of the C:\users folders in megabytes. 

The not-so-one-liner

I've created a module for this on GitHub, which will be updated more frequently than the code below. Check it out here

You can also install it from the PowerShell Gallery via:

Install-Module PSFolderSize

Then for help on how to run it, try:

Get-Help Get-FolderSize -Detailed

The latest version, 1.7.0, lets you sort the output via FolderName or SizeBytes as a CSV, JSON file, or XML file.

Moving on to how it all started!

This is my favorite. I'm not fond of one-liners as they are hard to reuse easily.

I created script, complete with comment-based help available with Get-Help

Here are some screenshots of the script in action:

.\Get-FolderSize.ps1
.\Get-FolderSize.ps1 -Path 'C:\Program Files'
$folderSizes = .\Get-FolderSize.ps1
$folderSizes | Where-Object {$_.'Size(MB)' -ne 'Empty'}
.\Get-FolderSize.ps1 -Path 'C:\Program Files' -Name IIS

Feel free to copy/paste, and use the code as you like! 
Let me know if you have any feedback below in the comments section.

<#
.SYNOPSIS

Get-FolderSize.ps1
Returns the size of folders in MB and GB.

.DESCRIPTION

This function will get the folder size in MB and GB of folders found in the basePath parameter. 
The basePath parameter defaults to C:\Users. You can also specify a specific folder name via the folderName parameter.

VERSION HISTORY:
1.0 - Updated object creation, removed try/catch that was causing issues 
0.5 - Just created!


.PARAMETER BasePath

This parameter allows you to specify the base path you'd like to get the child folders of.
It defaults to C:\.

.PARAMETER FolderName

This parameter allows you to specify the name of a specific folder you'd like to get the size of.

.PARAMETER AddTotal

This parameter adds a total count at the end of the array

.PARAMETER OmitFolders

This parameter allows you to omit folder(s) (array of string) from being included

.EXAMPLE 

.\Get-FolderSize.ps1
-------------------------------------

FolderName                Size(Bytes) Size(MB)     Size(GB)
----------                ----------- --------     --------
$GetCurrent                    193768 0.18 MB      0.00 GB
$RECYCLE.BIN                 20649823 19.69 MB     0.02 GB
$SysReset                    53267392 50.80 MB     0.05 GB
Config.Msi                            0.00 MB      0.00 GB
Documents and Settings                0.00 MB      0.00 GB
Games                     48522184491 46,274.36 MB 45.19 GB

.EXAMPLE 

.\Get-FolderSize.ps1 -BasePath 'C:\Program Files'
-------------------------------------

FolderName                                   Size(Bytes) Size(MB)    Size(GB)
----------                                   ----------- --------    --------
7-Zip                                            4588532 4.38 MB     0.00 GB
Adobe                                         3567833029 3,402.55 MB 3.32 GB
Application Verifier                              353569 0.34 MB     0.00 GB
Bonjour                                           615066 0.59 MB     0.00 GB
Common Files                                   489183608 466.52 MB   0.46 GB

.EXAMPLE 

.\Get-FolderSize.ps1 -BasePath 'C:\Program Files' -FolderName IIS
-------------------------------------

FolderName Size(Bytes) Size(MB) Size(GB)
---------- ----------- -------- --------
IIS            5480411 5.23 MB  0.01 GB

.EXAMPLE

$getFolderSize = .\Get-FolderSize.ps1 
$getFolderSize 
-------------------------------------

FolderName Size(GB) Size(MB)
---------- -------- --------
Public     0.00 GB  0.00 MB
thegn      2.39 GB  2,442.99 MB

.EXAMPLE

Sort by size descending 
$getFolderSize = .\Get-FolderSize.ps1 | Sort-Object 'Size(Bytes)' -Descending
$getFolderSize 
-------------------------------------

FolderName                Size(Bytes) Size(MB)     Size(GB)
----------                ----------- --------     --------
Users                     76280394429 72,746.65 MB 71.04 GB
Games                     48522184491 46,274.36 MB 45.19 GB
Program Files (x86)       27752593691 26,466.94 MB 25.85 GB
Windows                   25351747445 24,177.31 MB 23.61 GB

.EXAMPLE

Omit folder(s) from being included 
.\Get-FolderSize.ps1 -OmitFolders 'C:\Temp','C:\Windows'

#>
[cmdletbinding()]
param(
    [Parameter(Mandatory = $false)]
    [Alias('Path')]
    [String[]]
    $BasePath = 'C:\',        
    [Parameter(Mandatory = $false)]
    [Alias('User')]
    [String[]]
    $FolderName = 'all',
    [Parameter()]
    [String[]]
    $OmitFolders,
    [Parameter()]
    [Switch]
    $AddTotal
)

#Get a list of all the directories in the base path we're looking for.
if ($folderName -eq 'all') {

    $allFolders = Get-ChildItem $BasePath -Directory -Force | Where-Object {$_.FullName -notin $OmitFolders}

}
else {

    $allFolders = Get-ChildItem $basePath -Directory -Force | Where-Object {($_.BaseName -like $FolderName) -and ($_.FullName -notin $OmitFolders)}

}

#Create array to store folder objects found with size info.
[System.Collections.ArrayList]$folderList = @()

#Go through each folder in the base path.
ForEach ($folder in $allFolders) {

    #Clear out the variables used in the loop.
    $fullPath = $null        
    $folderObject = $null
    $folderSize = $null
    $folderSizeInMB = $null
    $folderSizeInGB = $null
    $folderBaseName = $null

    #Store the full path to the folder and its name in separate variables
    $fullPath = $folder.FullName
    $folderBaseName = $folder.BaseName     

    Write-Verbose "Working with [$fullPath]..."            

    #Get folder info / sizes
    $folderSize = Get-Childitem -Path $fullPath -Recurse -Force -ErrorAction SilentlyContinue | Measure-Object -Property Length -Sum -ErrorAction SilentlyContinue       
        
    #We use the string format operator here to show only 2 decimals, and do some PS Math.
    $folderSizeInMB = "{0} MB" -f ($folderSize.Sum / 1MB)
    $folderSizeInGB = "{0} GB" -f ($folderSize.Sum / 1GB)

    #Here we create a custom object that we'll add to the array
    $folderObject = [PSCustomObject]@{

        FolderName    = $folderBaseName
        'Size(Bytes)' = $folderSize.Sum
        'Size(MB)'    = $folderSizeInMB
        'Size(GB)'    = $folderSizeInGB

    }                        

    #Add the object to the array
    $folderList.Add($folderObject) | Out-Null

}

if ($AddTotal) {

    $grandTotal = $null

    if ($folderList.Count -gt 1) {
    
        $folderList | ForEach-Object {

            $grandTotal += $_.'Size(Bytes)'    

        }

        $totalFolderSizeInMB = " MB" -f ($grandTotal / 1MB)
        $totalFolderSizeInGB = " GB" -f ($grandTotal / 1GB)

        $folderObject = [PSCustomObject]@{

            FolderName    = 'GrandTotal'
            'Size(Bytes)' = $grandTotal
            'Size(MB)'    = $totalFolderSizeInMB
            'Size(GB)'    = $totalFolderSizeInGB
        }

        #Add the object to the array
        $folderList.Add($folderObject) | Out-Null
    }   

}
    
#Return the object array with the objects selected in the order specified.
Return $folderList

If you're just getting started in PowerShell, and would like some help, check out my series on Getting Started With Windows PowerShell.

As always, feedback is appreciated, and let me know if you have any questions.

-Ginger Ninja

 

 

Using $PSBoundParameters in PowerShell

Using $PSBoundParameters in PowerShell

What is $PSBoundParameters?

$PSBoundParameters in PowerShell is an automatic variable that is populated based on the parameters used with a function. It is a hashtable whose keys contain the parameter name used, and values contain the argument/value passed in. This can come in handy when you want to perform different tasks based on the parameters used in a function.

Automatic variables in PowerShell are variables that store a particular state. Although you can write to them, it is frowned upon to do so. For more information about automatic variables, check out this post: https://docs.microsoft.com/en-us/powershell/module/microsoft.powershell.core/about/about_automatic_variables?view=powershell-7.

Taking a look at $PSBoundParameters

Sometimes things make more sense if we can work with them tangibly.
Below is a function that will return the contents of $PSBoundParameters.

function Get-PSBoundParameters {
    [cmdletbinding()]
    param(
        [Parameter(

        )]
        [string]
        $ParamOne,

        [Parameter(

        )]
        [string]
        $ParamTwo
    )

    begin {
        
    }

    process {
        
    }

    end {

        return $PSBoundParameters

    }
}

Once we have this function in memory, we can call it, and get what is in $PSBoundParameters returned.

$params = Get-PSBoundParameters -ParamOne 'testOne' -ParamTwo 'testTwo'

In the above command, we are calling the function Get-PSBoundParameters, and passing testOne to ParamOne, and testTwo to ParamTwo.

Based on what we know now, the result should be a hashtable where the Keys contain ParamOne and ParamTwo, with the value of those keys being the arguments passed in.

Key Value
--- -----
ParamOne testOne
ParamTwo testTwo

params_visual.PNG

Working with the results as seen in the above image confirms this.

If you ever want to see what else an object can do, or the properties it has, pipe it to Get-Member.

You can see we have access to the various methods of $params, such as ContainsKey (which can be used to validate if a key (in this case parameter called) exists), and Add, which would let you add a key/value pair to the hashtable.

Working with $PSBoundParameters

Now that we’ve taken a peek under the hood, let’s work with $PSBoundParameters to take actions based on the parameters used when calling a function.

For this example, I have created a function that returns different results based on the parameters specified:

function Invoke-PSBoundParametersAction {
    [cmdletbinding()]
    param(
        [Parameter(

        )]
        [string]
        $ParamOne,

        [Parameter(

        )]
        [string]
        $ParamTwo,

        [Parameter(

        )]
        [string]
        $ParamThree
    )

    begin {

        #setup our return object
        $result = [PSCustomObject]@{

            SuccessOne = $false
            SuccessTwo = $false

        }        
    }

    process {

        #use a switch statement to take actions based on passed in parameters
        switch ($PSBoundParameters.Keys) {

            'ParamOne' {

                #perform actions if ParamOne is used
                $result.SuccessOne = $true
                
            }

            'ParamTwo' {

                #perform logic if ParamTwo is used
                $result.SuccessTwo = $true

            }

            Default {
                
                Write-Warning "Unhandled parameter -> [$($_)]"

            }
        }        
    }

    end {

        return $result

    }
}

Now let’s see it in action!

No parameters specified:

#test with no params
Invoke-PSBoundParametersAction 
noparam.PNG

As expected, the results indicate no change in the value of SuccessOne or SuccessTwo.

Now let’s try with just ParamOne:

#test with ParamOne
Invoke-PSBoundParametersAction -ParamOne 'value'
paramone.PNG

When we specify ParamOne, you can see it works as SuccessOne is now True.

Now let’s try with just ParamTwo:

#test with ParamTwo
Invoke-PSBoundParametersAction -ParamTwo 'value'
paramTwo.PNG

Looks good, now to give it a go with ParamOne and ParamTwo:

#test with ParamOne and ParamTwo
Invoke-PSBoundParametersAction -ParamOne 'value' -ParamTwo 'value'
bothParams.PNG

Looks like both of them had the appropriate action taken, as each success is set to True.

Now let’s see what happens if we use a parameter not specified in our switch statement:

#test with ParamThree
Invoke-PSBoundParametersAction -ParamThree 'value'

You can see our Write-Warning under the default switch clause worked.

Now let’s use all the parameters!

#test with ParamOne, ParamTwo, and ParamThree
Invoke-PSBoundParametersAction -ParamOne 'value' -ParamTwo 'value' -ParamThree 'value'
allthethings.PNG

Looks like the switch statement handled everything here.

Splatting the Parameters

Splatting in PowerShell is the act of taking a hashtable, and passing it as parameter/value pairs to a function. To do this, you call a function with a hashtable, but use the @ symbol instead of the $.

Here is a very simple example of splatting, with a slightly modified example set from above:

function Get-PSBoundParameters {
    [cmdletbinding()]
    param(
        [Parameter(

        )]
        [string]
        $ParamOne,

        [Parameter(

        )]
        [string]
        $ParamTwo
    )

    begin {

    }

    process {
        
    }

    end {

        Invoke-PSBoundParametersAction @PSBoundParameters

    }
}

function Invoke-PSBoundParametersAction {
    [cmdletbinding()]
    param(
        [Parameter(

        )]
        [string]
        $ParamOne,

        [Parameter(

        )]
        [string]
        $ParamTwo,

        [Parameter(

        )]
        [string]
        $ParamThree
    )

    begin {

        #setup our return object
        $result = [PSCustomObject]@{

            SuccessOne = $false
            SuccessTwo = $false

        }        
    }

    process {

        #use a switch statement to take actions based on passed in parameters
        switch ($PSBoundParameters.Keys) {

            'ParamOne' {

                #perform actions if ParamOne is used
                $result.SuccessOne = $true
                
            }

            'ParamTwo' {

                #perform logic if ParamTwo is used
                $result.SuccessTwo = $true

            }

            Default {
                
                Write-Warning "Unhandled parameter -> [$($_)]"

            }
        }        
    }

    end {

        return $result

    }
}

The second function is the same, but the first function has been modified to call the second function with $PSBoundParameters splatted.

Invoke-PSBoundParametersAction @PSBoundParameters

With these two functions in memory, let’s take a look at the result of calling the first function as such:

#test splatting
Get-PSBoundParameters -ParamOne 'value'
splat.PNG

Since the parameter names matched, it passed the parameter/values along to the second function.

Wrapping things up

As we’ve seen in this post, $PSBoundParameters can be handy depending on your use-case. Whether you are validating a parameter exists, or passing things along via splatting, it helps keep things more concise and clean versus the alternatives. If you have any questions or feedback, please leave a comment below!

Visual Studio Code PowerShell Setup

Visual Studio Code PowerShell Setup

vscodeps.png

Welcome to my Visual Studio Code/PowerShell series!

What is Visual Studio Code?

Visual Studio Code is a lightweight, open source, extensible code editor that you can do pretty much anything with. You can run it on Windows, Linux, and macOS. It also has built-in git support.

But what about the PowerShell ISE?

While the PowerShell ISE was a comfortable home for many, it is no longer being developed or added to.
“Windows” or the Desktop version of PowerShell, also called FullCLR, will no longer be getting new features, and will only receive security patches going forward.

PowerShell Core is the future of PowerShell, and should be used whenever possible. It is also open source, and like VS Code, works on pretty much any platform. I even use it on my Raspberry Pis!

It is an exciting time to be using PowerShell, and getting up and running with VS Code will make you even more of a code ninja.

You can read more about the PowerShell changes, here: https://devblogs.microsoft.com/powershell/powershell-core-6-0-generally-available-ga-and-supported/

Installing Visual Studio Code

Installing VS Code is easy! Simply go to https://code.visualstudio.com, and click the download link for your OS.

Once downloaded, run the installer.
If you need help installing VS Code, check out Microsoft’s documentation, here: https://code.visualstudio.com/docs/setup/setup-overview.

Open Visual Studio Code / Install Extensions

Now let’s open VS Code and get some extensions installed.

PowerShell Extension

The first extension we will install is the PowerShell extension.

In Visual Studio Code:

  • Click the extension icon

  • Search for PowerShell

  • Click Install

extensions.png
pwextinst.png

You may notice it also installs a theme, which you can use if you enjoy light themes. We will be installing a theme pack later to give you some more options.

vscode-icons

Now let’s install an icon pack, named vscode-icons

  • Click the extension icon

  • Search for vscode-icons

  • Click Install / then activate/set the theme.

vscode_icons.png
activate_icons.png

Themes!

Now let’s get some themes installed. The theme pack I like the most is Rainglow, and I recommend starting there.

  • Click the extension icon

  • Search for Rainglow

  • Click Install

rainglow.png

Set the theme

  • Use the keyboard shortcut CTRL+SHIFT+P (win/linux) or Command+SHIFT+P (macOS) to bring up the Command Palette. From there, ensure the character “> is to the left to indicate you’d like to search, and search for theme.

theme_command.png
  • Click the option Preferences: Color Theme

  • Feel free to dig through them, and see which one you like the most. I recommend starting with Codecourse Contrast (rainglow) (which you can search for to make selecting it quicker)

theme_select.png

PowerShell Versions

If you are running Windows, you should see the current PowerShell version in the lower right-hand corner.

ps_ver.png

This means that you can edit/debug PowerShell code in the Desktop/5.1 environment. If you’re not running Windows, you won’t see this as there is no version installed by default.

VS Code makes it very easy to switch between PowerShell environments. The next step is to get PowerShell Core installed.

Follow these instructions per your OS:

PowerShell Core and Windows PowerShell can happily co-exist on the same machine. Windows/Desktop PowerShell has the executable named powershell.exe, whereas PowerShell Core is pwsh.exe.

Once you have the latest version of PowerShell Core installed, you can do the following to switch your session in Visual Studio Code:

  • Click the [>]5.1 icon in the lower right-hand corner (or whatever version is specified)

  • At the top, select the PowerShell Core version you just installed

ps_session.png
6_2.png

You can use the same steps to switch back to other versions of PowerShell. If for some reason you do not see the icon in the lower right-hand corner, fret not, it should appear after the next section.

Hello World Script in VS Code

In this section we will be creating a simple script to demonstrate a few of the basics in Visual Studio Code. I will be expanding upon different features in the next parts of this series.

In VS Code:

  • Either use CTRL+N or click File -> New File

  • Once in the Untitled-1 document, use CTRL+S or click File -> Save

  • Save the file as hello.ps1, wherever you’d like.

new_file.png
save_prompt.png
save_file.png

Once you’ve performed the above steps, we will get Visual Studio Code setup with the hello.ps1 script ready to be edited. The Terminal will be at the bottom, and the script editor up above.

empty_hello.png

Time to add some code!

function Invoke-HelloWorld {
    param(
        [Parameter()]
        [switch]
        $Backwards,
        
        [Parameter()]
        [switch]
        $TitleCase
    )

    begin {

        $message         = 'This is the "Hello World" example!'
        $helloWorld      = [string]::Empty
        $titleCaseHelper = [System.Globalization.CultureInfo]::new([string]::Empty)

    }

    process {

        if ($Backwards) {
            
            $splitter = -1

            for ($i = 0; $i -lt $message.Split(' ').Count; $i++) {

                $helloWorld += "$($message.Split(' ')[$splitter]) "
                
                $splitter--       

            }            
            
        } else {

            $helloWorld = $message

        }

        if ($TitleCase) {

            $helloWorld = $titleCaseHelper.TextInfo.ToTitleCase($helloWorld.Trim())

        }
    }

    end {

        return $helloWorld.Trim()

    }

}

#Run with no switches specified
Invoke-HelloWorld

#Get the result from using the backwards switch
Invoke-HelloWorld -Backwards

#Get the result from using the title case switch
Invoke-HelloWorld -TitleCase

#Get the result from using the backwards and title case switch'
Invoke-HelloWorld -Backwards -TitleCase

Now that we have some code to look at, we can take a look at some of the features of VS Code.

Hide the Explorer Pane

To hide/un-hide the explorer pane, use CTRL+B:

hidden.png

Function Info / Completion

If you were to type:

Invoke-HelloWorld -

It shows the different parameters available, and their type on the right.

completion.png

When you start typing a function name, it will also list the available functions.

Invoke-
invoke.png

Running Code

While [F5] will run the entire script, you can use [F8] to run a selection.

Running a selection of code

Let’s start by doing the following:

  • Highlight the entirety of the function.

  • Hit [F8]. Note that this is the same as highlighting all the code you want to run, rick-clicking- and going to Run Selection.

f8.png

The end result here is that it save the function into memory, as we can see in the terminal window.

Now let’s scroll down below the function and run some of the commands that invoke the function we just stored in memory.

  • Highlight Invoke-HelloWorld, and run the selection [F8]. You can also highlight the comment on the line before it.

highlight_result.png

Your results should be similar as the above screenshot, in terms of output:

This is the “Hello World” example!

Now let’s run the last example, which uses both of the parameter switches:

  • Highlight the example Invoke-HelloWorld -Backwards -TitleCase, and run it [F8]

backtitle.png

Running the entire script (F5)

F5 will run the entire script, and also start debugging if you set any breakpoints.

This one is easy, simply hit [F5] or click Debug -> Start Debugging

Here is the output from doing just that:

f5.png

A Very Quick Example of Debugging

For this example I will add a breakpoint on line 49 (by clicking in the blank space to the left of the line number), which is the return line of the function:

debug.png

Now when we hit [F5] to debug, it will stop at this breakpoint, and give us some information about the state of the script.

On the left you will see the current state of any objects/variables:

Across the top there is a bar that controls the state of flow within the debugging experience.

debugcontrol.png

For now, let’s click the up arrow, which performs the Step Out action. You can also use Shift+F11.

The script will stop at the point each time it is hit, until execution is complete.

Congratulations

You’ve setup VS Code and have done some work to make it feel like home, and did some work with a PowerShell script. Poke around and try to break/fix some things to learn some more.

More to come

I will be going in depth on various VS Code + PowerShell topics in the next parts of this series. Let me know if there is something you’d like me to cover sooner rather than later!

Using Discord Webhooks and Embeds With PowerShell (Part 2)

Using Discord Webhooks and Embeds With PowerShell (Part 2)

embed_resized.png

Welcome to part 2 of my series of using PowerShell to send Discord webhooks. In this post I will be going over how to send embeds. If you’re just getting started with the process, I recommend reading Part 1 first.

Table of Contents

PSDsHook Module

I’ve created a module that makes it easy to work with embeds, store configurations that contain your webhook urls, and make the whole experience seamless by using PowerShell classes. It will get its own post, and if you’re interested check out the Github repo here.

Example of using the module:

Embeds!

If we take a look at the Discord developer documentation for webhook properties, this is what it looks like:

webhookresource.PNG

You can see there’s a field for embeds that accepts and array of embed objects. Embeds in Discord are a way to make your message look a little spiffier. 

Here is the documentation for embeds:

Here is an example of what one looks like from the teaser at the end of part one:

part2_discord.png

There are a lot of different features of embeds, and we’ll be covering a few of them. You can use this link to see how many different fields there are in an embed available to you.

In this post I will be focusing on sending an embed that contains a color (on the left side), a thumbnail image, title, and a description (essentially the content of the embed).

Creating and Sending the Embed

To get started making embeds, be sure to keep that webhook url handy from part 1.

  1. First let’s store that webhook url in a variable and create an empty array to add the embed(s) to.

$webHookUrl = "yourhookurlhere"
[System.Collections.ArrayList]$embedArray = @()

2. Now let’s store values for the title, description, and color.

  • Title and description are strings so those are easy. Color is also a string, but a string that represents a decimal value for the color. For now it is important to understand the value for green is 4289797.

$color = '4289797'
$title = 'Greetings from PowerShell!'
$description = 'This is an embed. It looks much nicer than just sending text over!'

3. Now it’s time to create a PSCustomObject that contains those items to add to the array we created earlier.

$embedObject = [PSCustomObject]@{
    color = $color
    title = $title
    description = $description
}

4. Let’s add that to our array.

$embedArray.Add($embedObject)

5. Taking a peek at the array, we can see the contents of our embed in it.

arraypeek.PNG

6. Now let’s test it by constructing the payload and sending it over to the webhook url via Invoke-RestMethod.

$payload = [PSCustomObject]@{
    embeds = $embedArray
}

Invoke-RestMethod -Uri $webHookUrl -Body ($payload | ConvertTo-Json -Depth 4) -Method Post -ContentType 'application/json'

7. Looking in the channel it should send to, it looks like the send was successful!

firstsend.PNG

Adding a Thumbnail

Now that we’ve constructed and sent an embed successfully, let’s send one with a thumbnail in it. To do this we’ll start from scratch, and apply much of what we did above to a new embed object.

First we’ll need to construct a thumbnail object. From the Discord developer documentation, we can see a thumbnail contains the following:

thumb.PNG

We’ll be using the url property to store a url to an image.

1. Create the thumbnail object.

$thumbnailObject = [PSCustomObject]@{
    url = "https://static1.squarespace.com/static/5644323de4b07810c0b6db7b/t/5aa44874e4966bde3633b69c/1520715914043/webhook_resized.png"
}

2. Create our embed object with the thumbnail in it.

$title       = 'Greetings with a picture!'
$description = 'This embed should now contain an image'
$color       = '9442302'

$embedObject = [PSCustomObject]@{
    title = $title
    description = $description
    color = $color
    thumbnail = $thumbnailObject
}

3. Now we’ll create an array, add the embed object, create the payload, and send that over to the webhook url.

[System.Collections.ArrayList]$embedArray = @()
$embedArray.Add($embedObject)

$payload = [PSCustomObject]@{
    embeds = $embedArray
}
Invoke-RestMethod -Uri $webHookUrl -Body ($payload | ConvertTo-Json -Depth 4) -Method Post -ContentType 'application/json'

Looking in the channel, we can see that it worked!

embedwiththumb.PNG

Github Repo With Example Code

I’m going to start putting examples together in Github so I can better keep them updated. Use this url for updated examples: https://github.com/gngrninja/blog/blob/master/DiscordWebhook/embeds.ps1

Summary

Embeds are a little more complicated, but not too hard to work in with these webhooks and PowerShell. There is even more you can do, including adding fields to embeds and sending files. In part 3 I will be go over just how to do that!

fields.PNG

Let me know if you have any ideas, questions, or feedback in the comments below!

PSElvUI, PowerShell ElvUI Updater Module

PSElvUI, PowerShell ElvUI Updater Module

What's it do?

This post is mostly for the World of Warcraft nerds out there. /raises hand
This module allows you to:

  • Check / Update ElvUI if there is a newer version

  • Install ElvUI if it is not already installed

  • Simply check if there is a new version, but do nothing else

Why? Isn't there already a tool for this?

There is a tool provided by the fine folks that created the AddOn, and it works great. I wanted a way to possible automate it (maybe a scheduled task on boot), and a fast way to check/update without having to login/launch anything other than PowerShell (which I happen to already have up most of the time anyway).

How can I install it?

Currently this is only working on Windows, with Mac support coming soon.

You can install the module from the PowerShell Gallery by using (as admin):

Install-Module PSElvUI

For more detailed instructions, check out the README.md here.

install.PNG

Using the module

Using the module is simple!

Check for update/don’t install:

Invoke-ElvUICheck -OnlyCheck -Verbose
checkonly.PNG

Check/Update if there is an update:

Invoke-ElvUICheck -Verbose
update.PNG

Install if it doesn’t exist:

Invoke-ElvUICheck -InstallIfDoesntExist -Verbose
install2.PNG

And that’s it! Leave a comment if you have any suggestions or ideas.

I gotta tissue if you have an issue, but also feel free to let me know what doesn’t work for you here.

Deep Dive

This module was also a way for me to get some Pester tests written. You can check out the code, as well as the tests written via the GitHub repo for this module.

Output for Invoke-Pester in the folder for this module:

pester.PNG

Get-WowInstallPath.tests.ps1

This file contains some very basic tests. It calls the functions and verifies that the paths returned exist via Pester’s exist function.

InModuleScope PSElvUi {

    describe 'Get-WowInstallPath' {

        $wowInfo = Get-WowInstallPath

        it 'Returns WoW Install Path' {

            $wowInfo.WoWInstallPath | Should Exist

        }

        it 'Finds addons folder in path' {

            $wowInfo.AddonsFolder | Should Exist

        }
    }
}

Get-WowInstallPath
This function uses the Windows registry to get the WoW install path. Mac support will do something different when it is added. Here is the code for now:

function Get-WoWInstallPath {
    [cmdletbinding()]
    param(

    )

    Write-Verbose "Attempting to find WoW install path..."

    try {

        $wowInstallPath = (Get-Item 'hklm:\SOFTWARE\WOW6432Node\Blizzard Entertainment\World of Warcraft').GetValue('InstallPath')
        $addonsFolder   = "$($wowInstallPath)Interface\AddOns" 

        $wowInstallInfo = [PSCustomObject]@{

            AddonsFolder   = $addonsFolder
            WowInstallPath = $wowInstallPath

        }

        return $wowInstallInfo
        
    }

    catch {

        $errorMessage = $_.Exception.Message 
        throw "Error determining WoW Install Path/ElvUi Version -> [$errorMessage]!"
        break

    }
}

It returns both the install path, and the addons folder location.

Finding the version number of ElvUI

Remote (Get-RemoteElvUiVersion)

The remote code check utilizes Invoke-WebRequest to get the version number. This method can fail when the website gets updated. There is no public API for this, so for now this is the method that works.

function Get-RemoteElvUiVersion {
    [cmdletbinding()]
    param(

    )
    
    try {
        
        $baseUrl      = 'https://www.tukui.org'
        $downloadPage = "$baseUrl/download.php?ui=elvui"
        $dlString     = '.+Download ElvUI.+'

        Write-Verbose "Attempting to retrieve ElvUI information from [$downloadPage]..."
        $downloadLink = "$baseUrl$(Invoke-WebRequest -Uri $downloadPage | 
                                    Select-Object -ExpandProperty Links | 
                                    Where-Object {
                                        $_.Outerhtml -match $dlString
                                    }                                   | 
                                    Select-Object -ExpandProperty href)" 
    
        $fileName             = $($downloadLink.Split('/')[4])
        [double]$elvUiVersion = $fileName.Split('-')[1].Replace('.zip','')
    
        $remoteElvInfo = [PSCustomObject]@{
    
            FileName     = $fileName
            Version      = $elvUiVersion
            DownloadLink = $downloadLink
    
        }
    
        return $remoteElvInfo 

    }
    catch {
        $errorMessage = $_.Exception.Message
        throw "Error getting remote ElvUI Information -> [$errorMessage]"
    }
}

Local (Get-LocalElvUiVersion)

The local version check loops through the contents of the ElvUI.toc file and matches on the ‘## Version”’ line. Then some string manipulation is used to grab the version. Error handling is in place to ensure issues are caught if they arise.

function Get-LocalElvUiVersion {
    [cmdletbinding()]
    param(
        [string]
        $addonsFolder
    )

    [double]$localVersion = 0.0

    if ((Test-Path $addonsFolder)) {

        try {

            $toc = Get-Content -Path "$addonsFolder\ElvUI\ElvUI.toc" -ErrorAction Stop

            $toc | ForEach-Object {

                if ($_ -match "## Version:") {
        
                    $localVersion = $_.Split(':')[1].trim()                    

                }
            }

            if ($localVersion -ne 0.0) {

                return $localVersion

            } else {

                throw 'No luck finding version in file'

            }
            

        }
        catch [System.Management.Automation.ItemNotFoundException] {

            throw "ElvUI addon not found!"

        }
        catch {            

            $errorMessage = $_.Exception.Message
            throw "Error determining ElvUI version -> [$errorMessage]!"

        }
        
    } else {

        throw "Unable to access WoW addon folder [$addonsFolder]!"

    }                
}

If you’ve gotten this far, I appreciate your interest! If you’d like more deep dives into the inner workings, leave a comment and let me know.

Using Discord Webhooks With PowerShell (Part 1)

Using Discord Webhooks With PowerShell (Part 1)

webhook_resized.png

What's a Discord Webhook?

A Discord Webhook allows you to send a message to a text channel auto-magically. It essentially provides a URL that is associated with a channel. You can customize the name of the sender, the avatar picture, and of course send over the contents of the mesage. More info on Discord Webhooks here.

Getting Started

The first step will be to create the Webhook in Discord.

1. Navigate to Server Settings on the server you want to create the hook on.

server_settings.png

2. Click Webhooks from the options on the left, and then Create Webhook on the right.

webhook.png
create_hook.png

3. Fill out the name of the hook, the channel you want it to hook into, and optionally associate an image.

hook_settings.png

4. Save the Webhook, and get the Webhook Url, as we'll need that later.

Using the Webhook in PowerShell

To utilize the Webhook in PowerShell, we'll make use of the Invoke-RestMethod command, and build a custom object as our payload.

1. Store the hook url we created earlier in a variable.

$hookUrl = "your hook url goes here"

2. Store the content in a variable (this is the message you'll be sending to the hook).

$content = @"
You can enter your message content here.

With a here-string, new lines are included as well!

Enjoy.
"@

3. Create the payload as a PowerShell custom object, in order to get the properties converted to JSON later.

$payload = [PSCustomObject]@{

    content = $content

}

4. Finally, use Invoke-RestMethod to utlize the Webhook, and post the message.

Invoke-RestMethod -Uri $hookUrl -Method Post -Body ($payload | ConvertTo-Json)

We are using the Post method with Invoke-RestMethod to send over the $payload

The payload is a PSCustomObject, which is great when working in PowerShell, but not-so-great when working with Web APIs, which almost always want something to be formatted as JSON (JavaScript Object Notation). 

That's why we use ($payload | ConvertTo-Json) with this Invoke-RestMethod call.

Here is what the object looks like:

payload_psobject.png

Here is what it looks like converted to JSON:

payload_asjson.png

Altogether, our code should now look like this:

$hookUrl = 'https://enterYourUrlHere'

$content = @"
You can enter your message content here.

With a here-string, new lines are included as well!

Enjoy.
"@

$payload = [PSCustomObject]@{

    content = $content

}

Invoke-RestMethod -Uri $hookUrl -Method Post -Body ($payload | ConvertTo-Json)

Note:

You may need to append:

-ContentType 'Application/Json'

To your Invoke-RestMethod command in later versions of PowerShell.

Let's run that, and see what happens in the Discord channel the hook is associated with.

hook_message.png

Success!

But wait... there's more!

Keep an eye out for part 2, where I'll expand upon what we can add to the payload. This includes a different user name, avatar icon, and even embeds!

part2_ps.png
part2_discord.png

Let me know if you have any trouble or ideas in the comment section, below!

I am also interested in hearing how you plan to use your PowerShell -> Discord Webhooks.

Happy Scripting!

PowerShell: Ninja Downloader (Modular File Downloading Utility)

PowerShell: Ninja Downloader (Modular File Downloading Utility)

Download Files With PowerShell Dynamically!

Knowing PowerShell can come in handy when you need to download files. Invoke-WebRequest is the command to get to know when working with web parsing, and obtaining downloads.

I've noticed, however, that different files show up as different content types, and parsing out the file name requires all sorts of voodoo. What if there was a way to use one tool that could utilize the power of PowerShell, and make downloading files a modular experience?

This tool, and blog post, are is inspired by folks asking me for help downloading files via PowerShell. I always appreciate feedback and questions, and this is exactly why!

Prerequisites

  • PowerShell 3.0+ 
  • Access to the internet

Ninja Downloader Overview

Ninja Downloader works by executing the main script (download.ps1), which takes the following parameters:

  • DownloadName
    • This is the name of the script you'd like to execute (use 'all' to execute all scripts)
    • Scripts are located in the .\scripts directory
    • Argument must omit the .ps1 extension of the script in the .\scripts directory
  • OutputType
    • This parameter let's you specify the output type, the default is none.
      • XML -> Export results as clixml
      • CSV -> Export results as a CSV file (default)
      • HTML -> Export results as an HMTL file
      • All -> Export results as all of the above
    • Output is exported to the .\output directory
  • DownloadFolder
    • This parameter allows you to specify a location to place the downloaded files
      • Folder will be created if it does not exist
    • If left empty the folder .\downloads will be used
  • UnZip
    • This parameter will look for zip archives after files are downloaded and attempt to extract them
      • Files extracted to .\downloads\fileName_HHmm-MMddyy\
  • ListOnly
    • This parameter (a switch) will give you a list of all possible names to use for DownloadName, as well as the paths to the scripts.

Downloading a File

There are several scripts included by default with tool. 

  • Ccleaner.ps1
  • Chrome.ps1
  • FireFox.ps1
  • Java.ps1
  • Skype.ps1
  • template.ps1 (never executed, this is a template for creating your own download script)

So how do we use them, then?

  1. Open PowerShell, and navigate to the root directory of the project/script.
  2. Run the following code:
$downloadResults = .\download.ps1 -DownloadName ccleaner

This will give us access to the results in $downloadResults.

downloadresults.PNG

You can see that the results we get include the name of the script executed, if it was executed successfully, any errors, and another object inside of this object named FileInfo.

FileInfo contains the file namepath to the file (full), any errors, and if we could verify it exists.

This attempt was successful, and our results echoed that! Let's take a look in the downloads folder just to be sure...

Awesome!

Downloading All Files

What if we wanted to download all the files via every script in the .\scripts folder?

  1. Open PowerShell, and navigate to the root directory.
  2. Run the following code:
$downloadResults = .\download.ps1 -DownloadName all -Verbose

This time we can see some of the output as it happens via the -Verbose switch.

Now let's take a look at $downloadResults:

For good measure, we'll also look at the downloads folder:

Alright! It worked.

Output Types

This script allows you output the results in various ways. All of the results will be time-stamped with the date and time.

CSV

To output results as a CSV, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType csv

After it runs, the results will be in the .\output folder.

csv.PNG

HTML

To output results as HTML, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType html

After it runs, the results will be in the .\output folder.

XML

To output results as XML, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType xml

After it runs, the results will be in the .\output folder.

All

To output results in all three formats, run:

$downloadResults = .\download.ps1 -DownloadName all -OutputType all

After it runs, the results will be in the .\output folder.

Creating Your Own Download Script

You can create your own script to use with the Ninja Downloader tool. The template provided is a working example of how Firefox is downloaded. The template is located in the .\scripts folder.

Template code:

#Template example (works for Firefox, adjust as needed for your download)

#Set this to the URL you'll be navigating to first
$navUrl    = 'https://www.mozilla.org/en-US/firefox/all/' 

#Text to match on, if applicable
$matchText = '.+windows.+64.+English.+\(US\)'

# IMPORTANT: This is the format of the object needed to be returned to the description
# Whichever way you get the information, you need to return an object with the following properties:
# DownloadName (string, file name)
# Content (byte array, file contents)
# Success (boolean)
# Error (string, any error received)
$downloadInfo = [PSCustomObject]@{

    DownloadName = ''
    Content      = ''
    Success      = $false
    Error        = ''  

} 

#Go to first page
Try {

    $downloadRequest = Invoke-WebRequest -Uri $navURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
Catch {

    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Look for urls that match
$downloadURL = $downloadRequest.Links | Where-Object {$_.Title -Match $matchText} | Select-Object -ExpandProperty href

#Go to matching URL, look for download file (keeping redirects at 0)
try {

    $downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
catch {
    
    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Get file info
$downloadFile = $downloadRequest.Headers.Location

#Parse file name, whichever way needed
if ($downloadRequest.Headers.Location) {
            
    $downloadInfo.DownloadName = $downloadFile.SubString($downloadFile.LastIndexOf('/')+1).Replace('%20',' ')

}  

#Switch out the StatusDescription, as applicable
Switch ($downloadRequest.StatusDescription) {

    'Found' {
                
        $downloadRequest = Invoke-WebRequest -Uri $downloadRequest.Headers.Location -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox 

    }

    default {

        $downloadInfo.Error = "Status description [$($downloadRequest.StatusDescription)] not handled!"
        
        return $downloadInfo

    }

}

#Switch out the proper content type for the file download
Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/x-msdos-program' {
                
        $downloadInfo.Content = $downloadRequest.Content
        $downloadInfo.Success = $true

        return $downloadInfo

    }
   
    Default {

        $downloadInfo.Error = "Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"
        
        return $downloadInfo

    }

}

What matters the most is that you return an object that has the following properties:

  • DownloadName
    • String value of the downloaded file name
  • Content
    • Byte array containing the actual file
  • Success
    • Boolean (true if the file was able to be downloaded)
  • Error
    • String representing any errors encountered

Example script creation:

For this example, let's download ElvUI (as we learned how to in detail, here).

First, I'll save the template as elvui.ps1

Then, based on our knowledge of web parsing (more here if you would like to learn more about Invoke-WebRequest), we can edit the template to reflect the correct web parsing needs and content type.

Full code for elvui.ps1

#Template example (works for Firefox, adjust as needed for your download)

#Set this to the URL you'll be navigating to first
$navUrl    = 'http://www.tukui.org/dl.php' 

# IMPORTANT: This is the format of the object needed to be returned to the description
# Whichever way you get the information, you need to return an object with the following properties:
# DownloadName (string, file name)
# Content (byte array, file contents)
# Success (boolean)
# Error (string, any error received)
$downloadInfo = [PSCustomObject]@{

    DownloadName = ''
    Content      = ''
    Success      = $false
    Error        = ''  

} 

#Go to first page
Try {

    $downloadRequest = Invoke-WebRequest -Uri $navUrl -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

}
Catch {

    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Look for urls that match
$downloadURL = ($downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}).href
$downloadInfo.DownloadName = $downloadURL.Substring($downloadURL.LastIndexOf('/')+1)

#Go to matching URL, look for download file (keeping redirects at 0)
try {
     
    $downloadRequest = Invoke-WebRequest -Uri $downloadURL 

}
catch {
    
    $errorMessage = $_.Exception.Message

    $downloadInfo.Error = $errorMessage

    return $downloadInfo

}

#Switch out the proper content type for the file download
Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/zip' {
                
        $downloadInfo.Content = $downloadRequest.Content
        $downloadInfo.Success = $true

        return $downloadInfo

    }
   
    Default {

        $downloadInfo.Error = "Content type [$($downloadRequest.BaseResponse.ContentType)] not handled!"
        
        return $downloadInfo

    }

}

Let's test it out!

.\download.ps1 -downloadName elvui

And there you have it, modular file downloading via PowerShell.

Github Repository

This project is available on Github!

Click here to go to the psNinjaDownloader repository.

You can download the contents as a ZIP file by going to 'Clone or download', and then selecting 'Download ZIP'.

If you download the code, and would like to run it, you'll need to unblock download.ps1 first.

To do this, right click download.ps1, and go to Properties, and check 'unblock', then click Apply/OK.

unblock.PNG

You'll also need to do this for all the scripts in the .\scripts folder

You can repeat the above step for every script in the .\scripts folderor we can use PowerShell to do it!

  1. In PowerShell, navigate to the .\scripts folder of psNinjaDownloader
  2. Run the following command:
Get-ChildItem | Unblock-File

Voila, you now have a working copy of the script!

What's Next?

  • Create your own download scripts, and test downloading things auto-magically.
  • Use this to download the latest version of tools as a schedule task
  • See the full help for the script by running: 
Get-Help .\download.ps1 -Full

If you have any feedback, questions, or issues, leave a comment here or contact me!

[top]

PowerShell Quick Tip: Using ValidateSet

PowerShell Quick Tip: Using ValidateSet

PowerShell Quick Tip: Using ValidateSet

Why Use ValidateSet?

ValidateSet is part of advanced parameters. It allows you to constrain the input of the parameter to a set of values. It also will autocomplete those options for you as you tab through them!

This can really come in handy if you only want to accept a specific set of options as input to the parameter. I've found it useful in the following scenarios:

  • Scripts that work with AD, and you'd like a specific set of options for a given parameters
  • Scripts that work with an API, and only a specific set of values are accepted
  • Scripts that are used in web parsing where you'd like to constrain the options for a specific parameter
  • You want to reduce the amount of logic in your script, and offload it to parameter validation (added bonus: error generation for you)

Using ValidateSet

Using ValidateSet is easy! You just add the following line above your parameter:

[ValidateSet('Green','Blue','Red')]

The above example will ensure the input to the parameter we create is either Green, Blue, or Red.

Here is a simple function I put together to demonstrate how this works:

function Write-Color {
    [cmdletbinding()]
    param(       
        [Parameter(Mandatory)]  
        [ValidateSet('Green','Blue','Red')]
        [string]
        $color,
        $message
    )

    Write-Host $message -ForegroundColor $color 

}

When a function is in memory in the ISE, and ValidateSet is used, it will actually give you a visual list of the available options!

With this function in memory, let's run these commands and see what happens:

Write-Color -color Blue -Message "Validate: Blue"
Write-Color -color Red -Message "Validate: Red"
Write-Color -color Green -Message "Validate: Green"

That worked!

What if we used a color that's not in the group specified?

Write-Color -color DarkBlue -message "Validate: DarkBlue"

The command will also auto-complete the options for you in the console if you specify -color and then hit tab.

Limitations

There are some limitations when doing this. 

  • If you set a default value to one outside the array of options, it will work as it only checks incoming input
Hard to see, but it worked even though DarkBlue isn't in the set above!

Hard to see, but it worked even though DarkBlue isn't in the set above!

 

  • You're unable to generate your own error messages based on what happens in the function
    • This is fine, though, as you can wrap this up in a Try/Catch outside the function!
function Write-Color {
    [cmdletbinding()]
    param(       
        [Parameter()]  
        [ValidateSet('Green','Blue','Red')]
        [string]
        $color,
        $message
    )

    Write-Host $message -ForegroundColor $color 

}

Try {

    Write-Color -color Yellow -message "This will not work!" 

}

Catch [System.Management.Automation.ParameterBindingException] {

    $errorMessage = $_.Exception.Message
    Write-Host "Error: [$errorMessage]" -ForegroundColor Red -BackgroundColor DarkBlue
    <#

    Code to handle error

    #>
}

Error message after running:

Instead of using Write-Host, and merely showing the error, you'd want to have code in place that takes action based on the specific event.

Wrap Up

That's about it for using ValidateSet! It can really come in handy, and save you time when writing out your scripts.

Do you use ValidateSet? Leave a comment, and let me know how you use it. I always love hearing different use cases.

PowerShell: Configure Your Scripts With a GUI!

PowerShell: Configure Your Scripts With a GUI!

Configure Your Scripts With a GUI!

Making a GUI in PowerShell is a relatively easy process. GUIs can really come in handy, too! You can use them in scripts to guide execution a specific way, and even ease people into using PowerShell scripts that may otherwise stray away from anything command-line. 

One of the coolest uses I've found for GUIs in PowerShell, is using them for script configuration. You run the script, set a parameter to true, and boom you have a GUI open that allows you to change and configure parts of the script the next time it executes. 

If you're on a team, and you don't want people to have to edit your scripts (actually, if you don't want YOU to even have to edit your scripts), this is the way to go.

Enough blabbing already, let's get to it!

In this post

Prerequisites

  • PowerShell 3.0+ (for this example)
  • Visual Studio Community (or above)
  • Script created in folder that contains the following subfolders:
    • Output
    • Input
      • Example:

A note about Write-Host

I use Write-Host in all my example code for one reason: readability. When you're actually scripting and writing code, you'll want to be sure to use something more versatile like Write-Verbose. Even better, use some logging functions to output to a log file and/or console.

Create a GUI in PowerShell

The GUI creation method I use involved using Visual Studio Community, and outlining the application in the XAML GUI editor. Then I use what I learned from Stephen Owen on his site, Foxdeploy. As this post will not be a how-to on creating GUIs in general, if you're interested in the behind the scenes on what will be happening here when it comes to the GUI, I highly recommend reading through his series, here: https://foxdeploy.com/2015/04/10/part-i-creating-powershell-guis-in-minutes-using-visual-studio-a-new-hope/

Creating Our GUI

The first thing we'll want to do is create our GUI. I will use Visual Studio Community for this.

1. Create new C# WPF Application

2. Design GUI via tools provided by Visual Studio

3. Copy the XAML generated by Visual Studio into PowerShell

Code so far

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

#Call function to open the form
Invoke-GUI

We can run the code now, and verify our GUI pops up as it should!

Now to get to work on making things happen!

Configuration File Creation

Let's add the following code to the top of our script:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

This gives us a parameter we can set later that will allow us to pop up the GUI (or not), and we setup some paths the script can use based on where it is run from. We also set the path to the configuration file we'll be creating and using.

Current folder structure:

Function for generating configuration file

Now let's create a function that will serve two purposes:

  • Accept input as the configuration file contents to export
  • Generate a base config if we don't pass any content to export
    • This is nice for first time script execution, and we can include some defaults that we'll expand upon later
function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

Next we'll add in an if statement that will:

  • Create a config file if it doesn't exist (and import it)
  • OR import a config file if it does exist
#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Full example code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

}

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

Execution of script with no configuration file:

We can take a peek in the input folder now to verify if it's there, and look at the contents.

Using the Configuration Data

Now that we have the configuration data available, and imported, we can use it in our script.

First, let's take a look at what the config data looks like after it is imported into PowerShell via Import-Clixml.

We have our data handy, and we can confirm that the OUList is indeed an array (as we'd want it to be), if we pipe $config to Get-Member.

With this data available to us, we can add some logic to our script to use it.

First, we'll add an if statement to the script that will determine if we want to launch the configuration GUI, or actually run through the script logic.

Code:

#Check for config, generate if it doesn't exist
if (!(Test-Path -Path $configFile)) { 

    Write-Host "Configuration file does not exist, creating!" -ForegroundColor Green -BackgroundColor Black
    
    #Call our function to generate the file
    Invoke-ConfigurationGeneration
    
    $script:configData = Import-Clixml -Path $configFile

} else {

    #Import file since it exists
    $script:configData = Import-Clixml -Path $configFile

}

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

In the code above, I have referenced a few different  functions well need for the script to run correctly.  They are Invoke-UserDiscovery and Invoke-UserAction.  

Here is the code for those functions:

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

Full code so far:

[cmdletbinding()]
param(
    [Boolean]
    $configScript = $false
)

#Setup paths
$scriptPath = Split-Path -parent $MyInvocation.MyCommand.Definition
$inputDir   = "$scriptPath\Input"
$outputDir  = "$scriptPath\Output"
$configFile = "$inputDir\config.xml"

function Invoke-UserDiscovery { #Begin function Invoke-UserDiscovery
    [cmdletbinding()]
    param()

    #Create empty arrayList object
    [System.Collections.ArrayList]$userList = @()

    #Create users and add them to array
    $testUser2 = [PSCustomObject]@{

        DisplayName   = 'Mike Jones'
        UserName      = 'jonesm'
        LastLogon     = (Get-Date).AddDays(-35) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }

    $testUser1 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-24) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }    

    $testUser3 = [PSCustomObject]@{

        DisplayName   = 'John Doe'
        UserName      = 'doej'
        LastLogon     = (Get-Date).AddDays(-10) 
        OU            = Get-Random -inputObject $script:configData.OUList

    }  

    $testUser4 = [PSCustomObject]@{

        DisplayName   = 'This WontWork'
        UserName      = 'wontworkt'        
        LastLogon     = $null 
        OU            = Get-Random -inputObject $script:configData.OUList

    }
    
    $testUser5 = [PSCustomObject]@{

        DisplayName   = 'This AlsoWontWork'
        UserName      = 'alsowontworkt'        
        LastLogon     = 'this many!'
        OU            = Get-Random -inputObject $script:configData.OUList

    }        

    #Add users to arraylist
    $userList.Add($testUser1) | Out-Null
    $userList.Add($testUser2) | Out-Null
    $userList.Add($testUser3) | Out-Null
    $userList.Add($testUser4) | Out-Null
    $userList.Add($testUser5) | Out-Null

    #Return list
    Return $userList

} #End function Invoke-UserDiscovery

function Invoke-UserAction { #Begin function Invoke-UserAction
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeline
        )]
        $usersToProcess
    )

    Begin { #Begin begin block for Invoke-UserAction

        #Create array to store results in
        [System.Collections.ArrayList]$processedArray = @()

        Write-Host `n"User processing started!"`n -ForegroundColor Black -BackgroundColor Green

    } #End begin block for Invoke-UserAction

    Process { #Begin process block for function Invoke-UserAction

        foreach ($user in $usersToProcess) { #Begin user foreach loop
        
            #Set variables to null so they are not set by the last iteration
            $lastLogonDays = $null
            $userAction    = $null
            
            $notes         = 'N/A'

            #Some error handling for getting the last logon days
            Try {

                #Set value based on calculation using the LastLogon value of the user
                $lastLogonDays = ((Get-Date) - $user.LastLogon).Days 

            }

            Catch {
            
                #Capture message into variable $errorMessage, and set other variables accordingly
                $errorMessage  = $_.Exception.Message
                $lastLogonDays = $null
                $notes         = $errorMessage 

                Write-Host `n"Error while calculating last logon days [$errorMessage]"`n -ForegroundColor Red -BackgroundColor DarkBlue

            }

            Write-Host `n"Checking on [$($user.DisplayName)], who last logged on [$lastLogonDays] days ago..."

            #Switch statement to switch out the value of $lastLogonDays
            Switch ($lastLogonDays) { #Begin action switch

                #This expression compares the value of $lastLogondays to the script scoped variable for warning days, set with the configuration data file
                {$_ -lt $script:configData.DisableDays -and $_ -ge $script:configData.WarnDays} { #Begin actions for warning

                    $userAction = 'Warn'

                    Write-Host "Warning, [$($user.DisplayName)] will be disabled in [$($script:configData.DisableDays - $lastLogonDays)] days!"`n

                    Break

                } #End actions for warning

                #This expression compares the value of $lastLogondays to the script scoped variable for disable days, set with the configuration data file
                {$_ -ge $script:configData.DisableDays} { #Begin actions for disable

                    $userAction = 'Disable'

                    Write-Host "[$($user.DisplayName)] is going to be disabled, and is [$($lastLogonDays - $script:ConfigData.DisableDays)] days past the threshold!"`n

                    Break

                } #End actions for disable

                {$_ -eq $null} { #Begin actions for a null value

                    $userAction = 'Error'

                    Write-Host "Something went wrong, no value specified for last logon days!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    Break

                } #End actions for a null value

                #Adding a default to catch other values
                default { #Begin default actions

                    $userAction = 'None'                    
                    Write-Host "$($user.DisplayName) is good to go, they last logged on [$($lastLogonDays)] days ago!"`n

                } #Begin default actions

            } #End action switch

            #Create object to store in array
            $processedObject = [PSCustomObject]@{
                
                DisplayName   = $user.DisplayName
                UserName      = $user.UserName
                OU            = $user.OU
                LastLogon     = $user.LastLogon
                LastLogonDays = $lastLogonDays
                Action        = $userAction                
                Notes         = $notes                

            }

            #Add object to array of processed users
            $processedArray.Add($processedObject) | Out-Null

        } #End user foreach loop

    } #End process block for function Invoke-UserAction

    End { #Begin end block for Invoke-UserAction

        Write-Host `n"User processing ended!"`n -ForegroundColor Black -BackgroundColor Green
        
        #Return array
        Return $processedArray

    } #End end block for Invoke-UserAction

} #End function Invoke-UserAction

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Show form
    $form.ShowDialog() | Out-Null

} #End function Invoke-GUI

function Invoke-ConfigurationGeneration { #Begin function Invoke-ConfigurationGeneration
    [cmdletbinding()]
    param(
        $configurationOptions
    )

    if (!$configurationOptions) { #Actions if we don't pass in any options to the function
        
        #The OU list will be an array
        [System.Collections.ArrayList]$ouList = @()

        #These variables will be used to evaluate last logon dates of users
        [int]$warnDays    = 23
        [int]$disableDays = 30

        #Add some fake OUs for testing purposes
        $ouList.Add('OU=Marketing,DC=FakeDomain,DC=COM') | Out-Null
        $ouList.Add('OU=Sales,DC=FakeDomain,DC=COM')     | Out-Null

        #Create a custom object to store things in
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #Export the object we created as the current configuration

        Write-Host "Exporting generated configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile

    } else { #End actions for no options passed in, being actions for if they are

        Write-Host "Exporting passed in options as configuration file to [$configFile]!"
        
        $configurationOptions | Export-Clixml -Path $configFile        

    } #End if for options passed into function

} #End function Invoke-ConfigurationGeneration

#Script logic
if ($configScript) { #Begin if to see if $configScript is set to true

    #If it's true, run this function to launch the GUI
    Invoke-GUI
    
} else { #Begin if/else for script exeuction (non-config)

    #Simple example for using the OUList defined in the config file
    ForEach ($ou in $script:configData.OUList) { #Begin foreach loop for OU actions

        Write-Host "Performing action on [$ou]!" -ForegroundColor Green -BackgroundColor Black

    } #End foreach loop for OU actions

    #Create some test users
    $userList = Invoke-UserDiscovery

    #Take actions on each user and store results in $processedUsers
    $processedUsers = $userList | Invoke-UserAction
    
    #Create file name for data export
    $outputFileName = ("$outputDir\processedUsers_{0:MMddyy_HHmm}.csv" -f (Get-Date))
    
    #Export processed users various data types
    $processedUsers | Export-Csv -Path $outputFileName -NoTypeInformation
    $processedUsers | Export-Clixml -Path ($outputFileName -replace 'csv','xml')

    Write-Host "File exported to [$outputDir]!"

    #Take a look at the array
    $processedUsers | Format-Table

} #End if/else for script actions (non-config)

Overview of what will happen:

  • If $configScript is set to $false, the else statement will execute
  • An example of using config data will be displayed for each of the OUs stored
  • We generate a list of users with some last logon values, and random OUs from the OU list (Invoke-UserDiscovery)
  • We then go through the users and process them, by piping the user array to Invoke-UserAction
  • We store those results in $processedUsers, and then export them to a CSV and Clixml file in the script's output directory
  • Finally, we pipe the results to Format-Table, to review in the console

Let's run the script as is, and see what happens!

You can see that it performed all the steps above, as we would expect it to.

The output directory now contains two files:

CSV File Contents:

Clixml File Contents:

Adding a Modifiable Configuration

Now onto the ability to modify and save the configuration via the GUI we created!

Normally, I stray away from creating functions within functions...

...but in this case I like the way it creates a clean and understandable layout. 

We're going to focus entirely on the function we created earlier, Invoke-GUI.

Here is the new code:

function Invoke-GUI { #Begin function Invoke-GUI
    [cmdletbinding()]
    Param()

    #We technically don't need these, but they may come in handy later if you want to pop up message boxes, etc
    [void][System.Reflection.Assembly]::LoadWithPartialName("System.Windows.Forms") 
    [void][System.Reflection.Assembly]::LoadWithPartialName('presentationframework')

    #Input XAML here
    $inputXML = @"
<Window x:Class="psguiconfig.MainWindow"
        xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
        xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
        xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
        xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
        xmlns:local="clr-namespace:psguiconfig"
        mc:Ignorable="d"
        Title="Script Configuration" Height="281.26" Width="509.864">
    <Grid>
        <Label x:Name="lblWarningMin" Content="Warning Days" HorizontalAlignment="Left" Height="29" Margin="10,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxWarnLow" HorizontalAlignment="Left" Height="20" Margin="96,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <Label x:Name="lblDisableMin" Content="Disable Days" HorizontalAlignment="Left" Height="29" Margin="134,6,0,0" VerticalAlignment="Top" Width="86"/>
        <TextBox x:Name="txtBoxDisableLow" HorizontalAlignment="Left" Height="20" Margin="220,6,0,0" TextWrapping="Wrap" Text="0" VerticalAlignment="Top" Width="27"/>
        <TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>
        <Label x:Name="lblOUs" Content="OUs To Scan" HorizontalAlignment="Left" Height="26" Margin="10,28,0,0" VerticalAlignment="Top" Width="80"/>
        <Button x:Name="btnExceptions" Content="Exceptions" HorizontalAlignment="Left" Height="43" Margin="252,6,0,0" VerticalAlignment="Top" Width="237"/>
        <Button x:Name="btnEdit" Content="Edit" HorizontalAlignment="Left" Height="29" Margin="10,212,0,0" VerticalAlignment="Top" Width="66"/>
        <Button x:Name="btnSave" Content="Save" HorizontalAlignment="Left" Height="29" Margin="423,212,0,0" VerticalAlignment="Top" Width="66"/>
    </Grid>
</Window>
"@  

    [xml]$XAML = $inputXML -replace 'mc:Ignorable="d"','' -replace "x:N",'N'  -replace '^<Win.*', '<Window' 
    
    #Read XAML 
    $reader=(New-Object System.Xml.XmlNodeReader $xaml) 
    try {
    
        $Form=[Windows.Markup.XamlReader]::Load( $reader )
        
    }

    catch {
    
        Write-Error "Unable to load Windows.Markup.XamlReader. Double-check syntax and ensure .net is installed."
        
    }
 
    #Create variables to control form elements as objects in PowerShell
    $xaml.SelectNodes("//*[@Name]") | ForEach-Object {
    
        Set-Variable -Name "WPF$($_.Name)" -Value $Form.FindName($_.Name) -Scope Global
        
    } 
    
    #Setup the form    
    function Invoke-FormSetup { #Begin function Invoke-FormSetup

        #Here we set the default states of the objects that represent the buttons/fields
        $WPFbtnEdit.IsEnabled          = $true
        $WPFbtnSave.IsEnabled          = $false
        $WPFtxtBoxWarnLow.IsEnabled    = $false
        $WPFtxtBoxDisableLow.IsEnabled = $false
        $WPFtxtBoxOUList.IsEnabled     = $false
        $WPFbtnExceptions.IsEnabled    = $false

        #We will use the current values we imported from the script scoped variable configData
        $WPFtxtBoxWarnLow.Text    = $script:configData.WarnDays
        $WPFtxtBoxDisableLow.Text = $script:configData.DisableDays
        $WPFtxtBoxOUList.Text     = $script:configData.OUList | Out-String

    } #End function Invoke-FormSetup

    function Invoke-FormSaveData { #Begin function Invoke-FormSaveData

        #This function will perform the action to save the form data
        
        #We setup the variables based on the current values of the form
        $warnDays     = [int]$WPFtxtBoxWarnLow.Text 
        $disableDays  = [int]$WPFtxtBoxDisableLow.Text
        $ouList       = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

        #This object will contain the current configuration we would like to export
        $configurationOptions = [PSCustomObject]@{

            WarnDays    = $warnDays
            DisableDays = $disableDays
            OUList      = $ouList

        }
        
        #We then pass the configuration to the function we created earlier that will export the options we pass in
        Invoke-ConfigurationGeneration -configurationOptions $configurationOptions

        #Then we re-import the config file after it is exported via the function above
        $script:configData = Import-Clixml -Path $configFile

        #Finally we revert the GUI to the original state, which will also reflect the lastest configuration that we just exported
        Invoke-FormSetup

    } #End function Invoke-FormSaveData

    #Now we perform actions using the functions we created, as well as code that runs when buttons are clicked

    #Run form setup on launch
    Invoke-FormSetup

    #Button actions
    $WPFbtnEdit.Add_Click{ #Begin edit button actions

        #This will 'open up' the form and allow fields to be edited
        $WPFbtnExceptions.IsEnabled    = $true
        $WPFbtnSave.IsEnabled          = $true
        $WPFtxtBoxWarnLow.IsEnabled    = $true
        $WPFtxtBoxDisableLow.IsEnabled = $true
        $WPFtxtBoxOUList.IsEnabled     = $true
        $WPFbtnExceptions.IsEnabled    = $true

    } #End edit button actions

    $WPFbtnSave.Add_Click{ #Begin save button actions

        #The save button calls the Invoke-FormSaveData function
        Invoke-FormSaveData

    } #End save button actions

    #Show the form
    $form.showDialog() | Out-Null

} #End function Invoke-GUI

Overview:

  • We add a function to save the data when the save button is clicked (Invoke-FormSaveData)
  • We add a function that resets the data on the form and displays the current configuration (Invoke-FormSetup)
  • We add some code that executes when the Edit and Save buttons are clicked

Notes:

The XAML has also been modified a bit (one line of it), to allow us to use the enter key, as well as to add a scrollbar (if needed).

The line that represents the OU list has had the following appended to it: 

AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"

Full line:

<TextBox x:Name="txtBoxOUList" HorizontalAlignment="Left" Height="153" Margin="10,54,0,0" TextWrapping="Wrap" VerticalAlignment="Top" Width="479" AcceptsReturn="True" ScrollViewer.VerticalScrollBarVisibility="Auto"/>

It was a bit tricky at first when I was learning how to save the OU text box value as an array, here is the code that made it happen:

$ouList = ($WPFtxtBoxOUList.Text | Out-String).Trim() -split '[\r\n]' | Where-Object {$_ -ne ''}

This bit of code allows you to split the text box into an array, and filter out any blank entries.

Running the script to configure the GUI

Now let's set the value of config parameter to $true when we run the script. This will launch the configuration GUI.

configrun.PNG

Time to test it out!

First, I'll simply add another OU.

I'll need to click Edit, and then add the OU:

Now when I click Save it should lock the form again, and display a message in the console.

configrun.PNG

To verify it worked, we can run the script without flagging the parameter to $true.

Here are the current results:

The IT OU has been added, and used!

We can also test changing the warning and disable thresholds, to see how that affects our users and their actions.

Run script in config mode:

I'll set the warning threshold to 10 days, and the disable threshold to 15 days:

Let's run the script again, and check out the results!

It worked! You now have a script that you can modify when you need to with a GUI.

Create Configuration Shortcut

We can create a shortcut that when used, will launch the script with $configScript set to $true, and launch the configuration GUI.

To do this we will need to go to the script directory, and create a new shortcut.

Then, we will set the location to C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe.

After that, we give it a name that makes sense.

Now we need to edit the properties of the shortcut to change a couple things.

First, you'll want to append this to the target: -noprofile -command "& '.\script.ps1' -configScript:$true"

Full Target Value: C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe -noprofile -command "& '.\script.ps1' -configScript:$true"

Finally, clear out anything in Start in:, and click OK.

You should now be able to use the shortcut to configure the script!

Code on GitHub

All of the code for this script is placed on GitHub, you can check it out here.

You can download the contents as a ZIP file by going to 'Clone or download', and then selecting 'Download ZIP'.

If you download the code, and would like to run it as it using the shortcut or console, you'll need to unblock script.ps1 first.

To do this, right click script.ps1, and go to Properties

Now you'll want to check 'unblock' , and click Apply/OK.

unblock2.PNG

Using the code:

You can then testing out the code by opening PowerShell, and browsing to where you extracted the contents. You can then run .\script.ps1.

cd C:\users\thegn\Desktop\code\
.\script.ps1

You can actually launch the configuration shortcut from here too!

& '.\Configure Script.lnk'

I'll change the warning threshold to 9 days and re-run the script to verify it is working.

Now to run the script and see if everyone at least gets a warning:

There you go, it worked! Please feel free to take whatever pieces of the code you need, and make them work to your heart's desire.

What Next?

  • Apply the logic learned here to scripts you want to have the ability to easily edit on the fly
  • Learn more, and dive in by getting that exceptions button to do... well anything!
  • Dive deeper into PowerShell GUI creation

[top]

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

PowerShell: Getting Started - Utilizing the Web: Part 4 (Controlling Internet Explorer via IE COM Object)

Why Control Internet Explorer with PowerShell?

I've covered using Invoke-WebRequest, as well as Invoke-RestMethod for basic and advanced tasks... why would we need anything else? Sometimes you'll try using Invoke-WebRequest, and you swear the data should be there, yet for some reason you just can't seem to parse it out. 

The reasons for this can vary. Typically, it is because the page renders something with a script, and you can only gather it with a browser being opened/controlled. If you're having a hard time parsing the data from Invoke-WebRequest, controlling IE can be a time saving solution. This can come in handy if you need to script up something quick, and then work on a long term solution after you know what's possible.

COM Object Instantiation

The first thing we will need to do is instantiate an instance of the Internet Explorer COM object. To do this, we'll use New-Object.

$ieObject = New-Object -ComObject 'InternetExplorer.Application'

$ieObject will store the object and contains the properties and methods we can work with. Let's take a peek:

$ieObject | Get-Member
gm.PNG

We will be using a few of these properties and methods as we work with controlling IE.

By default, the instance of Internet Explorer is not visible. For automation, that's great!
For demoing and testing, not so much!

Let's show the current instance of IE:

$ieObject.Visible = $true

There's our window.

After instantiating our object, we can use it to navigate to a website.

To do this, we will utilize the Navigate method, and pass along a string value to navigate to.

$ieObject.Navigate('http://www.tukui.org/forums/bb-login.php')

Now the browser should be at the website.

Form Elements/Information Gathering

Now that we have navigated to the site, we will need to login. To login, we'll first need do some some information gathering.

First, we will store the current page's document in a new variable named $currentDocument

Then, we will list out all of the tags named input, along with their associated types and names

$currentDocument = $ieObject.Document
$currentDocument.IHTMLDocument3_getElementsByTagName("input") | Select-Object Type,Name

This gives us enough information to proceed to the next section on filling out the form, however let's take a look at a couple more things.

What if we wanted to gather all the links?

$currentDocument.links | Select-Object outerText,href

If we wanted to get just the download URL, we could use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href
$downloadLink

Hmm... it appears that there are two identical links to the download page provided. To make sure we only grab one, we will need to use something like:

$downloadLink = $currentDocument.links | Where-Object {$_.outerText -eq 'Download'} | Select-Object -ExpandProperty href -First 1
$downloadLink

There we go!

Filling Out Forms and Clicking Buttons

Now that we have parsed out all of the input types, and have their names, we can proceed with filling out the form information.

Here they are for review:

Username

First let's set the username field. 

The username field is named user_login, so let's store that field in a variable named $userNameBox.

$userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

To set the username, we can change the value property to our username. I have a credential object which contains my username, and I will use that to set it. You can pass along any string value, but I highly recommend using a credential object, or other secure method (more for the password, but it at also omits even showing the raw string for your username in the code).

$userNameBox.value = $myCredentials.UserName

You can see that this is set on the website now!

Password

Now to set the password. To do this we'll use the same logic as we did for the username, but specify the name password.

$passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

The password object will take a string for the password (just as the username object did). I highly recommend using a credential object, and then using the GetNetworkCredential method, and then the Password property.

$passwordBox.value = $myCredentials.GetNetworkCredential().Password

Looks good!

Clicking Submit ("Log in »")

We've filled out our username and password, what now? Well... that button ain't gonna click itself! Let's click it via PowerShell.

There is no name set on the object for the button, so we'll use its type for uniqueness, and set the variable to store it that way ($submitButton).

$submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

Now to click it! There is a method on this object named Click.

$submitButton | Get-Member -MemberType method

Ok but for real now, let's 'click' it!

$submitButton.click()

And if all goes well, you'll see the next page and be logged in.

Now that we're logged in, we could:

  • Set the $currentDocument variable to $ieObject.Document as the value is now different since a new page has been loaded.
  • Parse out information we need by exploring the webpage.
  • Automate looking for a specific post

It really all depends on what you want to do. 

One thing I have done is wrote up a script for a project where I had to rename user IDs. I had a list of old IDs and new IDs, and would iterate through all of them (sometimes 300+), until they were all renamed. All with logging and results output for later review and verification. Once you have the basics down you can really expand into the realm of full automation, and reap the benefits of what PowerShell can do.

!!**Note**!!

After each time you use the Navigate method, or click a button, you'll want to wait for the object's status to not be busy. 

Here is one way to accomplish that.

Store this function in memory:

function Invoke-IEWait {
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

}

You can store it in the same script, or as part of a module. You can invoke this function whenever you need to now, and keep your code looking clean.

It's also good to always refresh your $currentDocument variable after a button click / page load. 

Here's some code for an example of when/how to use it:

#Set the URL we want to navigate to
$webURL = 'http://www.tukui.org/forums/bb-login.php'

#Create / store object invoked in a variable
$ieObject = New-Object -ComObject 'InternetExplorer.Application'

#By default it will not be visible, and this is likely how you'd want it set in your scripts
#But for demo purposes, lets set it to visible
$ieObject.Visible = $true

#Take a look at the object
$ieObject | Get-Member

#Navigate to the URL we set earlier
$ieObject.Navigate($webURL)

#Wait for the page to load
$ieObject | Invoke-IEWait

#Store current document in variable
$currentDocument = $ieObject.Document

Alright, time to clean things up.

Cleanup

Let's cleanup the mess we made.

First we will log out of the website:

$logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1
$logoutURL

Now that we've verified the URL (and only have one thanks to Select-Object along with -First 1), we can logout by navigating to it.

$ieObject.Navigate($logoutURL)

Now to verify...

Now to clean up the COM object we instantiated.

First we'll quit IE:

$ieObject.Quit()

Our IE window should have went poof.

If you really want to perform the best cleanup, you can follow that up with this (to release the COM object):

[void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

Example Code

Here's some example code. I took what we accomplished above, and broke parts of it out into functions.

To use the code you'll need an account on the Tukui forums.

Here's what it does:

  • Prompts for credentials
  • Navigates to the site
    • Enters credentials
    • Clicks Submit
    • Parses links / text from posts
    • Outputs results to text files
  • Logs out of site
  • Quits IE
  • Releases IE COM object
function Invoke-IEWait { #Begin function Invoke-IEWait
    [cmdletbinding()]
    Param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject
    )

    While ($ieObject.Busy) {

        Start-Sleep -Milliseconds 10

    }

} #End function Invoke-IEWait

function Invoke-SiteLogon { #Begin function Invoke-SiteLogon
    [cmdletbinding()]
    param()

    #Set the URL we want to navigate to
    $webURL = 'http://www.tukui.org/forums/bb-login.php'

    #Create / store object invoked in a variable
    $ieObject = New-Object -ComObject 'InternetExplorer.Application'

    #By default it will not be visible, and this is likely how you'd want it set in your scripts
    #But for demo purposes, let's set it to visible
    $ieObject.Visible = $true

    #Navigate to the URL we set earlier
    $ieObject.Navigate($webURL)

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Store current document in a variable
    $currentDocument = $ieObject.Document

    #Username field
    $userNameBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'user_login'}

    #Fill out username value
    $userNameBox.value = $myCredentials.UserName

    #Password field
    $passwordBox = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.name -eq 'password'}

    #Fill out password value
    $passwordBox.value = $myCredentials.GetNetworkCredential().Password

    #Submit button
    $submitButton = $currentDocument.IHTMLDocument3_getElementsByTagName('input') | Where-Object {$_.type -eq 'submit'}

    #Invoke click method on submit button
    $submitButton.click()

    #Wait for the page to load
    $ieObject | Invoke-IEWait

    #Return the object so we can work with it further in the script
    Return $ieObject

} #End function Invoke-SiteLogon

function Invoke-IECleanUp { #Begin function Invoke-IECleanUp
    [cmdletbinding()]
    param(
        [Parameter(
            Mandatory,
            ValueFromPipeLine
        )]
        $ieObject        
    )

    #Store logout URL
    $logoutURL = $currentDocument.links | Where-Object {$_.outerText -eq 'log out'} | Select-Object -ExpandProperty href -First 1

    #Use logout URL to logout via the Navigate method
    $ieObject.Navigate($logoutURL)

    #Wait for logout
    $ieObject | Invoke-IEWait

    #Clean up IE Object
    $ieObject.Quit()

    #Release COM Object
    [void][Runtime.Interopservices.Marshal]::ReleaseComObject($ieObject)

} #End function Invoke-IECleanUp

#Get credentials
$myCredentials = Get-Credential

#Login to site
$ieObject = Invoke-SiteLogon

#Wait in case it is still busy
$ieObject | Invoke-IEWait

#Set the current document variable 
$currentDocument = $ieObject.Document

#Get all elements that may have text values in a table, and store in a text file
$currentDocument.IHTMLDocument3_getElementsByTagName('td') | Select-Object OuterText | Out-File '.\siteStuff.txt'

#Get all links and select name/href, and store in a text file
$currentDocument.links | Select-Object outerText,href | Out-File '.\links.txt'

#Log out / clean up IE Object now that we're done
$ieObject | Invoke-IECleanUp

It will create the text files in the current working directory.

Homework

I've provided some links for hints.

I hope you've enjoyed this series.
As always, leave a comment if you have any feedback or questions!

[top]

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 3 (More Invoke-RestMethod)

Welcome to my Getting Started with Windows PowerShell series!

Continuing With Invoke-RestMethod

In part 2, we went over Invoke-RestMethod's basics, including:

In this post, I will be going over some more ways to use Invoke-RestMethod to authenticate to different APIs. I will also go over how to send information to the API, and work with the results we get back.

API Key In Header

Some APIs will require you to authenticate with a key in the header information. I happen to own a LIFX light, and their API uses that method of authentication.

If you own a LIFX light, and want to follow along, you can get a personal access token here: https://cloud.lifx.com/settings.

Information recap:

Now let's get to work! I have my key, which let's just say is: 'YouNeedYourOwnKey123321$'

Let's store that in a variable.

$apiKey          = 'YouNeedYourOwnKey123321$'

The next step here is to store that key in the header information (as a hashtable), with the key name of "Authorization".

$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}

Let's take a peek at $headers to ensure it contains what we need.

Looks good! Now to check it out. The URL in the documentation to get the lights available is 'https://api.lifx.com/v1/lights/all'. I like to store information like this in variables. 

$allURL          = 'https://api.lifx.com/v1/lights/all'

Now to put it all to use! We'll be utilizing Invoke-RestMethod with the following parameters/arguments:

  • Headers
    • Since we already have our headers stored in a nifty hashtable, we'll simply specify $headers
  • Uri
    • We'll pass along $allURL, and attempt to get a list of available lights

Here's all of the code so far. 

We'll be storing the results of the Invoke-RestMethod command in $ninjaLights.

$apiKey          = 'YouNeedYourOwnKey123321$'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$allURL          = 'https://api.lifx.com/v1/lights/all'

$ninjaLights     = Invoke-RestMethod -Headers $headers -Uri $allURL -ContentType 'application/json'

Success! 

Sending Data to a REST API via Body (LIFX API)

Now that we've authenticated to LIFX, we can work on sending information over, and control the light. Here are the steps I generally follow when using an API for the first time:

  • Review documentation
  • Review documentation (This can't be emphasized enough)
  • Test out a bunch of different ways to utilize the API
    • Get errors, handle errors, see what's expected, clean up the code, and build a function

Here is a snippet from their documentation:

What that tells us is how the text is to be formatted (JSON)and that we'll be using the PUT method.

Here are the parameters they accept:

Let's get started by ensuring we have working code, and then build a function around it.

The URL is 'https://api.lifx.com/v1/lights/all/state', which we'll store in $lightStateURL. We'll also setup some variables for the accepted parameters, as follows:

  • $state
    • on or off, we'll choose on
  • $color
    • We'll set this to red
  • $brightness
    • We'll set this to .5, or 50%
  • $duration
    • This will be set to .0, which is indefinitely

After setting up these variables, we'll use a here-string to build the payload, and finally use Invoke-RestMethod with the following parameters/arguments:

  • Uri
    • The $lightStateURL variable
  • Method
    • We'll be using PUT, per their documentation
  • Headers
  • Body
    • We will set this to $payloadBuilder (which is the here-string that contains the parameters we're passing to the LIFX API)

Here is the full code so far to test this:

$apiKey          = 'YouNeedYourOwnKey123321$'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$allURL          = 'https://api.lifx.com/v1/lights/all'

$lightStateURL = 'https://api.lifx.com/v1/lights/all/state'

$state      = 'on'
$color      = 'red'
$brightness = 0.5
$duration   = 0.0

$payload = [PSCustomObject] ${

    power      = $state,
    color      = $color,
    brightness = $brightness,
    duration   = $duration
}
$jsonPayload = $payload | ConvertTo-Json

$setResults = Invoke-RestMethod -Uri $lightStateURL -Method Put -Headers $headers -Body $jsonPayload -ContentType 'application/json'

Let's go ahead and run that to see if it worked:

This is an easy one to verify!

We can also take a look at the results property of $setResults.

Now that we know that it works, it can be used in a function to control the light.

Here is the code for an example of how to build a function with this information:

$apiKey          = 'YouNeedYourOwnKey123321'
$headers         =  @{Authorization=("Bearer {0}" -f $apiKey)}
$baseURL         = 'https://api.lifx.com/v1/lights'

function Set-LightState { #Begin function Set-LightState
    [cmdletbinding()]
    Param (
        [Parameter(Mandatory)]
        $headers,
        [String]
        $state = 'on',
        [String]
        $color = 'White',
        [Double]
        $brightness = 0.5,
        [Double]
        $duration = 0.0
    )
    
    #Get lights
    $ninjaLights = Invoke-RestMethod -Headers $headers -Uri $allURL

    #If our light shows as connected...
    if ($ninjaLights.Connected) {

        #Set the selected as the ID of the light (per LIFX documentation)
        $selector = $ninjaLights.id
        
        #Construct the URL to include the selector and /state
        $fullURL  = "$baseurl/$selector/state"        

        #Build the payload
        $payload = [PSCustomObject] @{

            power      = $state
            color      = $color
            brightness = $brightness
            duration   = $duration
        }

        #Convert payload to JSON
        $jsonPayload = $payload | ConvertTo-Json

        Write-Host "Changing light from:" 
        Write-Host `t"State     : $($ninjaLights.Power)" 
        Write-Host `t"Color     : $($ninjaLights.Color)"
        Write-Host `t"Brightness: $($($ninjaLights.Brightness * 100))%"`n

        Write-Host "Changing light to:" 
        Write-Host `t"State     : $state" 
        Write-Host `t"Color     : $color"
        Write-Host `t"Brightness: $($brightness * 100)%" 
        Write-Host `t"Duration  : $duration"`n

        #Store results in $setResults
        $setResults = Invoke-RestMethod -Uri $fullURL -Method Put -Headers $headers -Body $jsonPayload -ContentType 'application/json'
    
        Write-Host "API status:"
        Write-Host `t"Light :" $setResults.results.label
        Write-Host `t"Status:" $setResults.results.status `n
    
    }

} #End function Set-LightState

Set-LightState -headers $headers -color blue

Let's see what happens when we run it! 

(NOTE) I use Write-Host here merely as a tool to give you a visual of the data you are working with. Ultimately it would be best to create some custom objects, and return the results as you need to with the code you're writing.

The information here is provided to show you what you can do, how you can build a payload, and then work with the data you are returned. You can do anything you put your mind to, from silly things (the next example), to even using it with another script (that may get weather information), and then flash the light red a few times if there is a weather alert.

For this next example, I will utilize a loop, and our new function, to confuse my girlfriend. Well, with all the testing I've been doing... maybe not anymore ;) 

Here's the code:

[array]$colors   = @('white','red','orange','yellow','cyan','green','blue','purple','pink')

$originalBrightness = $ninjaLights.Brightness
$originalColor      = $ninjalights.Color
$originalState      = $ninjalights.Power
$colorString        = "hue:" + $originalcolor.hue + " saturation:" + $originalcolor.saturation + " kelvin:" + $originalColor.Kelvin
        
$i = 0
        
While ($i -le 20) {
            
    $color              = Get-Random $colors
    [double]$brightness = "{0:n2}" -f (Get-Random -Maximum 1 -Minimum 0.00)
 
    Set-LightState -headers $headers -color $color -brightness $brightness -state $state
    Start-Sleep -seconds 1

    $i++
            
}
        
Set-LightState -headers $headers -state $originalState -color $colorString -brightness $originalBrightness

And off we go!

It worked! 

At the end it will set the light back to the value it was discovered with:

If you'd like a deeper dive into this, check out my post on using the Lifx API with PowerShell, here: http://www.gngrninja.com/script-ninja/2016/2/6/powershell-control-your-lifx-light

Username/Password in Request URL (PRTG API)

Some APIs authenticate you via including a username/password (hopefully eventually password hash) in the request URL. PRTG has one of those APIs. PRTG is one of my favorite monitoring tools, as not only is it great out of the box, but it also has great synergy with PowerShell.

PRTG information:

Let's start by getting the credentials of the account you want to use with the API. This account will be an account that has access to do what you need to do in PRTG. I will use the PRTG admin account, but you'll want to ensure you use one setup just to use with the API.

$prtgCredential = Get-Credential

If you're demoing PRTG, and are using a self-signed cert, you'll need the following code to allow Invoke-RestMethod to work with the self-signed cert. This code will only affect your current session.

Add-Type @"
using System.Net;
using System.Security.Cryptography.X509Certificates;

    public class TemporarySelfSignedCert : ICertificatePolicy {
        public TemporarySelfSignedCert() {}
        public bool CheckValidationResult(
            ServicePoint sPoint, X509Certificate cert,
            WebRequest wRequest, int certProb) {
                return true;
            }
    }
"@

[System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

Now let's setup a variable that contains our PRTG hostname, and a custom object with information for the API URLs.

$hostName = 'localhost'
$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

Next, per PRTG's documenation, we'll construct the URL we need to use to get the password hash.

$getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"

Now we can finally use Invoke-RestMethod to get the hash:

$getHash    = Invoke-RestMethod -Uri $getHashURL

Let's make sure $getHash has our password hash.

Alright, got it! Now we can create a new PS Credential object to store our username, and password hash (instead of our password).

First. we'll convert the hash we created into a secure string. Then, we will create a new PS Credential object using the username we specified in $prtgCredential, and the secure string we just created as the password.

$passwordHash        = ConvertTo-SecureString  $getHash -AsPlainText -Force
$prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

We can then verify the information by displaying the contents of $prtgCredentialwHash, and also using the GetNetworkCredential().Password method.

Now that we know $prtgCredentialwHash contains what we need, we can construct a URL to test out the API.

Let's set some variables up:

$prtgUser         = $prtgCredentialwHash.UserName
$prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

$credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

We can check the value of $credentialAppend to ensure it is correct:

Now to construct the URL, and test out the API via Invoke-RestMethod.

Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend)

Note: The full URL that is constructed is (I cut out my actual password hash on purpose, you'll see your hash after the = if all went well):

If all went well, you will see the results of the request (if not, you'll see an unauthorized message).

Success!

Sending Data via URL (PRTG API)

The PRTG API accepts data in the URL of the request. The below example will pause a sensor for a specific duration, with a specific message:

#Set the $duration (in minutes)
$duration = 10
#Set $sensorID to the sensor we want to pause
$sensorID = 45
#Set the pause message
$message  = "Paused via PS PRTG"

#Construct the $pauseURL
$pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"

#Use Invoke-RestMethod with the $pauseURL + $credentialAppend
#We will always append $credentialAppend as this is how the API accepts authentication
Invoke-RestMethod -Uri ($pauseURL + $credentialAppend)

Awesome, it worked!

Mega Example With Concept Code

This last example contains some concept code for a project I'm working on. Feel free to judge and use it as you wish, however I will note now that it is nowhere near finalized. I'm still in the exploration, see what's possible, and try to get it all to work phase. 

Requirements for concept code to work:

  • PRTG Installaction
  • Folder structure
  • Module
    • NinjaLogging.psm1 (included in ZIP file below)

NOTE: I have not fully cleaned up or officially released any of the code yet. That includes the logging module. 

Here is my TODO list for the code:

  • Upload it to GitHub
  • Clean up/add error handling
  • Create readme files
  • Add parameter sets to some of the psprtg.ps1 functions
  • Fully convert psprtg.ps1 to a module
  • Add functionality to psprtg.ps1 to include an 'oh crap!' undo feature
    • This will include exporting a custom object with the device ID and action taken

With that said, here is a link to download it (or you can skip to the code if that's all you want to see):

psPRTG download

How to get it working/examples:

  • Import the script as a module via Import-Module .\psprtg.ps1
    • The first time you do this, it will ask for your PRTG credentials. The module will then export the credential object to the input folder, and then use that exported credential the next time the module is used
  • Contents of machine/user encrypted credential file stored in the input directory:

 

  • Find a device and pause it via Invoke-SensorModification -findDevice
    • Note: You can also specify -Duration and -Message
  • Use a text file list of devices to find and take an action on

Pausing example:

Resuming example:

Log File Contents:

Code

psprtg.ps1

#Setup
$scriptPath      = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition
$moduleDir       = "$scriptPath\modules"
$logDir          = "$scriptPath\logs"
$outputDir       = "$scriptPath\output"
$inputDir        = "$scriptPath\input"
$hashFile        = "$inputDir\prtgCredentialwHash.xml"
$hostName        = 'localhost'
$modules         = ('ninjaLogging')

$prtgInfo = [PSCustomObject]@{

    BaseURL        = "https://$hostName/api"
    SensorCountURL = "https://$hostName/api/table.xml?content=sensors&columns=sensor"
    
}

function Invoke-ModuleImport { #Begin function Invoke-ModuleImport
    [cmdletbinding()]
    param($modules)

    Push-Location

    ForEach ($module in $modules) {

        if (Get-Module -ListAvailable -Name $module) {
    
            Import-Module $module
    
        } elseif (!(Get-Module -Name $module)) {
              
            Write-Verbose "Importing module: [$module] from: [$moduleDir\$module]" 
            Import-Module "$moduleDir\$module"

        } Else {

            Write-Verbose "Module [$module] already loaded!"

        }

    }

    Pop-Location

} #End function Invoke-ModuleImport

function Invoke-CredentialCheck { #Begin function Invoke-CredentialCheck
    [cmdletbinding()]
    Param()

    Try { #Begin try for credential path existence

        #Check if our hashed credential object exists
        if (Test-Path $hashFile) { #Begin if for testing credential path existence 

            #Import credentials from hash file

            Write-LogFile -logPath $logFile -logValue "Importing credentials from [$hashFile]" -Verbose

            $prtgCredentialwHash = Import-Clixml $hashFile

            #Add to info hash

            $prtgInfo | Add-Member -Type NoteProperty -Name Credentials -Value $prtgCredentialwHash

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"
            
            Return $credentialAppend

        #If it doesn't exist, attempt to create it
        } else {

            Write-Host "We need to get your hashed password to use with the API!" `n
            Write-Host "Please enter your PRTG credentials"`n

            #Store credential in $prtgCredential
            $prtgCredential = Get-Credential

            #Get the password hash via ConvertTo-SecureString and the Get-PRTGPasswordHash function.
            #Note: If you are not using a self-signed certificate (hopefully not, but if it is a demo/test install you may be): use -selfSignedCert:$True
            $passwordHash   = ConvertTo-SecureString  "$(Get-PRTGPasswordHash -PRTGCredential $prtgCredential -selfSignedCert:$True)" -AsPlainText -Force

            #Create new credential object and export it to the input folder
            #This allows us to use it when the script runs
            $prtgCredentialwHash = New-Object System.Management.Automation.PSCredential ($($prtgCredential.UserName), $passwordHash)

            Write-LogFile -logPath $logFile -logValue "Exporting hashed credentials to [$hashFile]." -Verbose

            #Export to file
            $prtgCredentialwHash | Export-Clixml $hashFile

            #create credential string for URLs
            $prtgUser         = $prtgCredentialwHash.UserName
            $prtgPass         = $prtgCredentialwHash.GetNetworkCredential().Password

            $credentialAppend = "&username=$prtgUser&passhash=$prtgPass"

            Return $credentialAppend

        }  #End if for testing credential path existence

    } #End try for credential path existence

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while checking for credentials/importing credentials: [$errorMessage]!"
    
        #Resolve the log file
        Resolve-LogFile -logPath $logFile

        Break

    }

} #End function Invoke-CredentialCheck

function Get-PRTGPasswordHash { #Begin function Get-PRTGPasswordHash
    [cmdletbinding()]
    param(
        [Parameter(Mandatory)]
        [PSCredential]
        $PRTGCredential,

        [Parameter()]
        [Boolean]
        $selfSignedCert = $false
    )

    if ($selfSignedCert) {

        Try {

            Add-Type @"
                using System.Net;
                using System.Security.Cryptography.X509Certificates;

                    public class TemporarySelfSignedCert : ICertificatePolicy {
                    public TemporarySelfSignedCert() {}
                    public bool CheckValidationResult(
                        ServicePoint sPoint, X509Certificate cert,
                        WebRequest wRequest, int certProb) {
                        return true;
                    }
                }
"@

            [System.Net.ServicePointManager]::CertificatePolicy = New-Object TemporarySelfSignedCert

    }

        Catch {
        
            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Error while allowing self signed certs: [$errorMessage]" -ForegroundColor Red -BackgroundColor DarkBlue
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break


        }

    }

    Try {

        $getHashURL = "$($prtgInfo.BaseURL)/getpasshash.htm?username=$($prtgCredential.userName)&password=$($prtgCredential.GetNetworkCredential().Password)"
        $getHash    = Invoke-RestMethod -Uri $getHashURL 

        Return $getHash

    }

    Catch {

        $errorMessage = $_.Exception.Message

        Write-LogFileError -logPath $logFile -errorDesc "Error while getting hash: [$errorMessage]" -Verbose

        Break

    }

} #End function Get-PRTGPasswordHash

function Invoke-DeviceSearch { #Begin function Invoke-DeviceSearch
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        $findMe
    )

    [System.Collections.ArrayList]$foundDeviceIDs = @()

    ForEach ($find in $findMe) {

        Try {

            $getDeviceURL       = "$($prtgInfo.BaseURL)/table.json?content=devices&output=json&columns=objid,probe,group,device,host,downsens,partialdownsens,downacksens,upsens,warnsens,pausedsens,unusualsens,undefinedsens"
            $devices            = Invoke-RestMethod -Uri ($getDeviceURL + $credentialAppend) 
            $findDeviceWildCard = '*' + $find + '*'

            ForEach ($device in $devices.devices) {

                Switch ($device.device) {

                    {$_ -like $findDeviceWildCard} {
                    
                        $foundDeviceIDs.Add($device.objid) | Out-Null
                    
                    }

                }

            } 
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose

        }

    }

    Return $foundDeviceIDs

} #End function Invoke-DeviceSearch

function Invoke-SensorModification { #Begin function Invoke-SensorModification
    [cmdletbinding()]
    Param(
        [parameter(Mandatory)]
        [string]
        $action,
        [parameter()]
        [string]
        $findDevice,
        [Parameter()]
        [int]
        $sensorID,
        [Parameter()]
        [String]
        $message,
        [Parameter()]
        [int]
        $duration,
        [Parameter()]
        $List
    )

    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    if (!$duration) {

        $duration = 30

    }

    if (!$message) {

        $message = "Sensor paused by: [$((Get-ChildItem Env:\USERNAME).Value)] via PS PRTG."    

    }

    if ($List) {
        
        Switch ($List | Get-Member | Select-Object -ExpandProperty TypeName -Unique) {

            {$_ -eq 'System.String'} {

                if ($list -like '*.txt') {
                
                    Write-Host "File detected" 

                    $deviceList = Get-Content $list

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                } else {

                    Write-Host 'Single string or array detected'

                    $deviceList = $list 

                    $foundDeviceIDs = Invoke-DeviceSearch -findMe $deviceList

                }

            }

            {$_ -eq 'System.Management.Automation.PSCustomObject'} {

                Write-Host 'Custom Object detected'

            }

        }

    }

    if ($findDevice) {

        Try {
            
            $foundDeviceIDs = Invoke-DeviceSearch -findMe $findDevice
        
        }

        Catch {

            $errorMessage = $_.Exception.Message

            Write-LogFileError -logPath $logFile -errorDesc "Unable to get device list: [$errorMessage]" -Verbose
            
            #Resolve the log file
            Resolve-LogFile -logPath $logFile

            Break
        
        }

    }

    Switch ($action) { #Begin switch for PRTG action

        {$_ -eq 'Pause'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                    $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)   
                
                } 

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $pauseURL     = "$($prtgInfo.baseURL)/pauseobjectfor.htm?id=$sensorID&pausemsg=$message&duration=$duration"
        
                Write-LogFile -logPath $logFile -Value "Attempting to pause sensor ID: [$sensorID], for duration of [$duration], with message [$message]" -Verbose
            
                $pauseAttempt = Invoke-WebRequest -Uri ($pauseURL + $credentialAppend)

                #Resolve the log file
                Resolve-LogFile -logPath $logFile 
            

            }

        }

        {$_ -eq 'Resume'} {

            if ($findDevice -or $List) {

                ForEach ($id in $foundDeviceIDs) {

                    $sensorID     = $null
                    $sensorID     = $id

                    $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                    Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                    $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
                
                }
                
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            } 
            
            Else { 

                $resumeURL     = "$($prtgInfo.baseURL)/pause.htm?id=$sensorID&action=1"
        
                Write-LogFile -logPath $logFile -Value "Attempting to resume sensor ID: [$sensorID]." -Verbose
            
                $resumeAttempt =  Invoke-WebRequest -Uri ($resumeURL + $credentialAppend)
            
                #Resolve the log file
                Resolve-LogFile -logPath $logFile 

            }

        }

        Default {

            Write-LogFileError -logPath $logFile -errorDesc "Unable to perform action [$action], as it does not match any valid actions!" -Verbose

            Resolve-LogFile -logPath $logFile 

            Break

        }

    } #End switch for PRTG action

} #End function Invoke-SensorModification

#Test API
Try{

    #Import modules 
    ForEach ($module in $modules) {Invoke-ModuleImport -modules $module}

    #Create log file
    $logFile = New-logFile -logPath $logDir -logName 'PSPRTG.log' -addDate:$true

    #Attempt to import/set credentials and get the returned string to append to the request for authentication
    $credentialAppend = Invoke-CredentialCheck
    
    #Attempt to get information as a test to see if the API will work
    Invoke-RestMethod -uri ($prtgInfo.SensorCountURL + $credentialAppend) | Out-Null

    #If it works, set TestPassed as true
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $true
    
}

Catch {

    $errorMessage = $_.Exception.Message
    
    Write-LogFileError -logPath $logFile -errorDesc "Error while attempting to use API: [$errorMessage]" -Verbose

    #If it doesn't work, set TestPassed as false and break out
    $prtgInfo | Add-Member -MemberType NoteProperty -Name TestPassed -Value $false

    #Resolve the log file
    Resolve-LogFile -logPath $logFile

    Break

}

NinjaLogging.psm1

Set-StrictMode -Version Latest

$scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition

Switch ($MyInvocation.PSCommandPath) {

    {$_ -match '\\\\'} {
    
    
        $scriptName = $_.SubString($_.LastIndexOf('\')+1)
    
    }

    Default { 
    
        $scriptName = Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName
        
    }

}

if ('Count' -in ($scriptName.psobject.Members.Name)) {
    
    $scriptName = 'ScriptLog'
    
}

function New-LogFile {
<#
.SYNOPSIS
   New-LogFile will create a log file.

.DESCRIPTION
   New-LogFile will create a log file. 

   You can specify different paramaters to change the file's name, and where it is stored.
   By default it will attempt to get the name of the calling function or script via $scriptName = (Get-ChildItem $MyInvocation.PSCommandPath | Select-Object -ExpandProperty BaseName).
   It will also attempt to get the path via $scriptPath = Split-Path -Parent -Path $MyInvocation.MyCommand.Definition.
   You can also specify the path and name, as well as if you'd like to append the date in the following format: MM-dd-yy_HHmm.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logfile

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER scriptVersion
    Type : Double

    Specify the version of your script being run. If left blank, will default to 0.1

.PARAMETER addDate
    Type : Boolean

    Specify if you'd like to add the date to the file name.  If you're specifying logName, you can use addDate to append the current date/time in the format: MM-dd-yy_HHmm.

.NOTES
    Name: New-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    

.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE
    $logFile = New-LogFile -Verbose
    -----------------------------
    
    VERBOSE: No path specified. Using: C:\PowerShell\logs
    VERBOSE:
    VERBOSE: No log name specified. Setting log name to: ScriptLog.log and adding date.
    VERBOSE:
    VERBOSE: Adding date to log file with an extension! New file name:
    ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: Created C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: File C:\PowerShell\logs\ScriptLog_05-11-16_1613.log created and verified to
    exist.
    VERBOSE:
    VERBOSE: Adding the following information to:
    C:\PowerShell\logs\ScriptLog_05-11-16_1613.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:13:11]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    gngrNinja>

.EXAMPLE
    $logfile = New-LogFile -Name 'testName.log' -path  'c:\temp' -addDate $true -Verbose
    -----------------------------

    VERBOSE: Adding date to log file with an extension! New file name:
    testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: Created c:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: File C:\temp\testName_05-11-16_1615.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\testName_05-11-16_1615.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:15:13]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:

.OUTPUTS
    Full path to the log file created.
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [double]
        $scriptVersion = 0.1,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addDate = $false
    )

#Check if file/path are set
    if (!$logPath) {
        
        $logPath = "$scriptPath\logs"
        
        Write-Verbose "No path specified. Using: $logPath"
        Write-Verbose ""
        
    }
    
    if (!$logName) {
        
        $logName = $scriptName + '.log'
        $addDate = $true
        
        Write-Verbose "No log name specified. Setting log name to: $logName and adding date."
        Write-Verbose ""
      
    }

    #Check if $addDate is $true, take action if so
    if ($addDate) {
        
        if ($logName.Contains('.')) {
            
            $logName = $logName.SubString(0,$logName.LastIndexOf('.')) + "_{0:MM-dd-yy_HHmm}" -f (Get-Date) + $logName.Substring($logName.LastIndexOf('.'))
            
            Write-Verbose "Adding date to log file with an extension! New file name: $logName"
            Write-Verbose ""
           
        } else {
            
            $logName = $logName + "_{0:MM-dd-yy_HHmm}" -f (Get-Date)
            
            Write-Verbose "Adding date to log file. New file name: $logName"
            Write-Verbose ""
            
        }
         
    }
    
    #Variable set up
    $time     = Get-Date
    $fullPath = $logPath + '\' + $logName
    $curUser  = (Get-ChildItem Env:\USERNAME).Value
    $curComp  = (Get-ChildItem Env:\COMPUTERNAME).Value
    
    #Checking paths / Creating directory if needed
    
    if (!(Test-Path $logPath)) {
        
        Try {
            
            New-Item -Path $logPath -ItemType Directory -ErrorAction Stop | Out-Null
            
            Write-Verbose "Folder $logPath created as it did not exist."
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Exception.Message
            
            Write-Output "Could not create folder due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
          
        }
    
    }
    
    #Checking to see if a file with the name name exists, renaming it if so.
    if (Test-Path $fullPath) {
        
        Try {
            
            $renFileName = ($fullPath + (Get-Random -Minimum ($time.Second) -Maximum 999) + 'old')
            
            Rename-Item $fullPath -NewName ($renFileName.Substring($renFileName.LastIndexOf('\')+1)) -Force -ErrorAction Stop | Out-Null
            
            Write-Verbose "Renamed $fullPath to $($renFileName.Substring($renFileName.LastIndexOf('\')+1))"
            Write-Verbose ""
            
        }
        
        Catch {
            
            $message = $_.Excetion.Message
            
            Write-Output "Could not rename existing file due to an error. Aborting. (See error details below)"
            Write-Error $message
            
            Break
            
        }
        
    }
    
    #File creation
    Try {
        
        New-Item -Path $fullPath -ItemType File -ErrorAction Stop | Out-Null
        
        Write-Verbose "Created $fullPath"
        Write-Verbose ""
        
    } 
    
    Catch {
        
        $message = $_.Exception.Message
        
        Write-Output "Could not create directory due to an error. Aborting. (See error details below)"
        Write-Error $message
        
        Break
        
    }
    
    #Get the full path in case of dot sourcing
    $fullPath = (Get-ChildItem $fullPath).FullName
    
    if (Test-Path $fullPath) {
        
        $flairLength = ("Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]").Length + 1
        
        Write-Verbose "File $fullPath created and verified to exist."
        Write-Verbose ""
        Write-Verbose "Adding the following information to: $fullPath"
        Write-Verbose ""
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Started logging at [$time]"
        Write-Verbose "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value "Started logging at [$time]"
        Add-Content -Path $fullPath -Value "Script (Version $scriptVersion) executed by: [$curUser] on computer: [$curComp]"
        Add-Content -Path $fullPath -Value ('-'*$flairLength)
        Add-Content -Path $fullPath -Value ""
        
        Return [string]$fullPath
         
    } else {
        
        Write-Error "File $fullPath does not exist. Aborting script."

        Break
        
    }
       
}

function Write-LogFile {
<#
.SYNOPSIS
   Write-LogFile will add information to a log file created with New-LogFile.

.DESCRIPTION
   Write-LogFile will add information to a log file created with New-LogFile.

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Write-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!'
    -----------------------------

    gngrNinja> more $logfile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:19:37]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:23:24] test log value!

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test log value!' -Verbose
    -----------------------------
    
    VERBOSE: Adding [05-11-16 16:25:19] test log value! to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Get-Process | Write-LogFile $logFile -Verbose
    -----------------------------

    ...
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (wininit) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (winlogon) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WmiPrvSE) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:26:51] System.Diagnostics.Process (WUDFHost) to
    C:\PowerShell\logs\ScriptLog_05-11-16_1619.log
    VERBOSE:
    ...

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFile -logPath $logFile -logValue 'test without timestamp' -addTimeStamp $false -Verbose
    -----------------------------

    VERBOSE: Adding test without timestamp to C:\PowerShell\logs\ScriptLog_05-11-16_1631.log
    VERBOSE:
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory                       = $true,
                   ValueFromPipeline               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 1)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 2)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {

        if (!(Test-Path $logPath)) {
        
            Write-Error "Unable to access $logPath"

            Break
        
        } 

    }

    Process {

        ForEach ($value in $logValue) {
        
            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }
         
    }

}

function Write-LogFileError {
<#
.SYNOPSIS
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileError will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER errorDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.
    
.NOTES
    Name: Write-LogFileError
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileError -logPath $logFile -errorDesc 'test log value error!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:31:44]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:31:51] [ERROR ENCOUNTERED]: test log value error!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $errorDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"
            Break

        }

    }

    Process {
     
        ForEach ($value in $errorDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[ERROR ENCOUNTERED]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
 
    
    
}

function Write-LogFileWarning {
<#
.SYNOPSIS
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

.DESCRIPTION
   Write-LogFileWarning will add information to a log file created with New-LogFile. The information will be prepended with [ERROR].

   By default additions to the log file will include a timestamp, unless you specify -addTimeStamp $false.
   This function accepts values from the pipeline, as demonstrated in an example.

   Use the -Verbose parameter to display what is being logged to the host.
   
.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path to the log file, including the name.

.PARAMETER warningDesc
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged as errors.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    This parameter let's you specify $true if you'd like to exit the script after the error is logged. 
    It defaults to $false.

.NOTES
    Name: Write-LogFileWarning
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16

.LINK
    http://www.gngrninja.com

.EXAMPLE
    For this example we'll assume you use:
    $logFile = New-LogFile 

    Write-LogFileWarning -logPath $logFile -warningDesc 'test log value warning!'
    -----------------------------

    gngrNinja> more $logFile
    -----------------------------------------------------------------
    Started logging at [05/11/2016 16:38:29]
    Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    -----------------------------------------------------------------

    [05-11-16 16:38:48] [WARNING]: test log value warning!
#>
    [CmdletBinding()]
        param(
            [Parameter(Mandatory = $true,
                       Position  = 0)]
            [Alias('Path')]
            [string]
            $logPath,
            [Parameter(Mandatory                       = $true,
                       ValueFromPipeline               = $true,
                       ValueFromPipelineByPropertyName = $true,
                       Position                        = 1)]
            [string]
            $warningDesc,
            [Parameter(Mandatory = $false,
                       Position  = 2)]
            [boolean]
            $addTimeStamp = $true,
            [Parameter(Mandatory = $false,
                       Position  = 3)]
            [Alias('Exit')]           
            [boolean]
            $exitScript = $false
        )
    
    Begin {

        if (!(Test-Path $logPath)) {
    
            Write-Error "Unable to access $logPath"

            Break

        }

    }

    Process {
     
        ForEach ($value in $warningDesc) { 

            $timeStamp = "[{0,0:MM}-{0,0:dd}-{0,0:yy} {0,0:HH}:{0,0:mm}:{0,0:ss}]" -f (Get-Date)

            $value     = "[WARNING]: $value"
        
            if ($addTimeStamp) {
            
                $value = "$($timeStamp + ' ' + $value)"
           
            }
        
            Write-Verbose "Adding $value to $logPath"
            Write-Verbose ""
        
            Add-Content -Path $logPath -Value $value
            Add-Content -Path $logPath -Value ''

        }

    }

    End {
         
         if ($exitScript) {
            
            Write-Verbose "Performing log file close command: Resolve-LogFile -logPath $logPath -exitonCompletion $true"
            Write-Verbose ""
            
            Resolve-LogFile -logPath $logPath -exitScript $true
            
        }

    }
    
}

function Resolve-LogFile {
<#
.SYNOPSIS
   Resolve-LogFile will resolve a created log file.

.DESCRIPTION
   Resolve-LogFile will resolve a created log file.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the full path, including name, to the log file to be resolved.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER exitScript
    Alias: Exit
    Type : Boolean

    Specify $true if you'd like to exit the script after the log file is resolved. 
    It defaults to $false.

.NOTES
    Name: Resolve-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $logFile = New-LogFile 
    -----------------------------
    
    gngrNinja> $logFile
    C:\PowerShell\logs\ScriptLog_05-11-16_1612.log

.EXAMPLE

    $logFile = New-LogFile 
    Get-Process | Write-LogFile $logFile

    Resolve-LogFile $logFile
    -----------------------------
    
    ...
    [05-11-16 16:43:58] System.Diagnostics.Process (wininit)

    [05-11-16 16:43:58] System.Diagnostics.Process (winlogon)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:43:58] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:44:01]
    ---------------------------------------------
#>
    [cmdletbinding()]
    param(
        [parameter(Mandatory = $true,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Exit')]
        [boolean]
        $exitScript = $false
    )
    
    $time = Get-Date
    
    if (Test-Path $logPath) {
        
        $flairLength = ("Finished processing at [$time]").Length + 1
        
        Write-Verbose "Adding the following content to: $logPath"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose "Ended logging at [$time]"
        Write-Verbose ('-'*$flairLength)
        Write-Verbose ""
        
        Add-Content -Path $logPath -Value ('-'*$flairLength)
        Add-Content -Path $logPath -Value "Ended logging at [$time]"
        Add-Content -Path $logPath -Value ('-'*$flairLength)
   
    } else {
        
        Write-Error "Unable to access $logPath"

        Break
        
    }
    
    if ($exitScript) {
        
        Write-Verbose "Exiting on completion specified, exiting..."
        
        Exit
        
    } 
   
}

function Out-LogFile {
<#
.SYNOPSIS
   Out-LogFile will create, add to, and resolve a logfile.

.DESCRIPTION
   Out-LogFile will create, add to, and resolve a logfile.

   Value from the pipeline is accepted.

   Use the -Verbose parameter to display what is happening to the host.

.PARAMETER logPath
    Alias: Path
    Type : String

    Specify the path to the logFile you'd like created.

.PARAMETER logName    
    Alias: Name
    Type : String

    Specify the name of the log file. Be sure to include the extension if specifying the name.

.PARAMETER logValue
    Alias: Value
    Type : String

    Specify the value(s) you'd like logged.

.PARAMETER addTimeStamp
    Type : Boolean

    Defaults to true, set to false if you'd like to omit the timestamp.

.NOTES
    Name: Out-LogFile
    Version: 1.0
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/11/16
    
.LINK
    http://www.gngrninja.com

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -logPath c:\temp -logName 'outlog.log' -Verbose 
    -----------------------------
    
    VERBOSE: Created c:\temp\outlog.log
    VERBOSE:
    VERBOSE: File C:\temp\outlog.log created and verified to exist.
    VERBOSE:
    VERBOSE: Adding the following information to: C:\temp\outlog.log
    VERBOSE:
    VERBOSE: -----------------------------------------------------------------
    VERBOSE: Started logging at [05/11/2016 16:58:43]
    VERBOSE: Script (Version 0.1) executed by: [thegn] on computer: [GINJA10]
    VERBOSE: -----------------------------------------------------------------
    VERBOSE:
    VERBOSE: Adding [05-11-16 16:58:43] System.Diagnostics.Process (AdobeUpdateService) to
    C:\temp\outlog.log
    VERBOSE:

.EXAMPLE
    $outLog = Get-Process | Out-LogFile -Verbose
    -----------------------------
    
    gngrNinja> more $outLog
    ...
    [05-11-16 16:56:02] System.Diagnostics.Process (winlogon)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WmiPrvSE)

    [05-11-16 16:56:02] System.Diagnostics.Process (WUDFHost)

    ---------------------------------------------
    Ended logging at [05/11/2016 16:56:02]
    ---------------------------------------------

gngrNinja>
#>
    [cmdletbinding()]
    param(
        [Parameter(Mandatory = $false,
                   Position  = 0)]
        [Alias('Path')]
        [string]
        $logPath,
        [Parameter(Mandatory = $false,
                   Position  = 1)]
        [Alias('Name')]
        [string]
        $logName,
        [Parameter(Mandatory = $true,
                   ValueFromPipeLine               = $true,
                   ValueFromPipelineByPropertyName = $true,
                   Position                        = 2)]
        [Alias('Value')]
        [string]
        $logValue,
        [Parameter(Mandatory = $false,
                   Position  = 3)]
        [boolean]
        $addTimeStamp = $true
    )
    
    Begin {
        
        $logFile = New-LogFile -logPath $logPath -logName $logName

    }
    
    Process {

        ForEach ($value in $logValue) {
        
            Write-LogFile -logPath $logFile -logValue $value -addTimeStamp $addTimeStamp
        
        }


    }
    
    End {
    
        Resolve-LogFile $logFile 

        Return $logFile

    }
    
}

function Send-LogEmail {
    [cmdletbinding()]
    param(
        [Parameter(Mandatory=$true)]
        [string]
        $To,
        [Parameter(Mandatory=$true)]
        [string]
        $Subject,
        [Parameter(Mandatory=$true)]
        [string]
        $Body,
        [Parameter(Mandatory=$true)]
        [string]
        $emailFrom,
        [Parameter(Mandatory=$true)]
        [string]
        $emailUser,
        [Parameter(Mandatory=$false)]
        [string]
        $provider = 'gmail',
        [Parameter(Mandatory=$true)]
        $password = (Read-Host "Password?" -AsSecureString)
    )

    if (!$to)        {Write-Error "No recipient specified";break}
    if (!$subject)   {Write-Error "No subject specified";break}
    if (!$body)      {Write-Error "No body specified";break}
    if (!$emailFrom) {$emailFrom = 'Ninja_PS_Logging@gngrninja.com'}
   
    Switch ($provider) {

        {$_ -eq 'gmail'} {
            
            $SMTPServer   = "smtp.gmail.com"
            $SMTPPort = 587

        }

        {$_ -eq 'custom'} {

            $SMTPServer = 'Your.SMTP.Server'
            $SMTPPort   = 'Your.SMTP.Server.Port'

        }

    }

    $gmailCredential = New-Object System.Management.Automation.PSCredential($emailUser,$password)
    Send-MailMessage -To $to -From $emailFrom -Body $body -BodyAsHtml:$true -Subject $Subject -SmtpServer $smtpServer -Port $smtpPort -UseSsl -Credential $gmailCredential

}

Notes:

  • I've spent some time on the help with the NinjaLogging.psm1 module
    • Feel free to browse through and use it as you wish, let me know if you have any problems!
  • I have not finished the email sending feature
  • This code will eventually find its way to Github, and be a lot more polished

Homework

  • Find an API for an application you'd like control with PowerShell
    • Study up on the documentation, and find ways to send data to it
    • Automate something using what you've learned today

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

PowerShell: Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Getting Started - Utilizing the Web: Part 2 (Invoke-RestMethod)

Invoke-RestMethod

If Invoke-WebRequest had a brother, it would be Invoke-RestMethod. Well… brother, cousin, sister… you get the idea! They are related. Invoke-RestMethod allows us to send requests to REST web services (Representational State Transfer), and then returns to us a lovely object to use as we need to.

If we get an RSS feed as a response, we’ll get XML as the returned data type. When working with APIs, and using Invoke-RestMethod, you’ll get a custom object back. This is because more times than not we'll get the information back as JSON (JavaScript Object Notation). PowerShell automatically converts that into the custom object, which you can then dive into the properties of.

Here are the different methods we can send:

  • Default
  • Delete
  • Get
  • Head
  • Merge
  • Options
  • Patch
  • Post
  • Put
  • Trace

 

Whether we’re working with an RSS feed, or an API of sorts, Invoke-RestMethod allows us to do so with ease. After learning how to use it, it has quickly become one of my favorite commands in PowerShell.

Using Invoke-RestMethod

Let's go into some examples of how to use Invoke-RestMethod. To start out, we will use it to get some information via RSS, and then dive a little deeper into APIs.

RSS Feeds

Invoke-RestMethod can be used to gather information from RSS feeds. For this example, we'll get the feed for http://www.reddit.com/r/powershell. To do that, we simply append .rss to the URL.

Here's the command:

$redditRSS = Invoke-RestMethod 'http://www.reddit.com/r/PowerShell.rss'

Let's pipe our variable to Get-Member, and see what's up.

$redditRSS | Get-Member

It looks like we indeed have an XML object, and its associated methods/properties. 

Let's see what the data looks like!

$redditRSS

The object we have here has a lot of properties that we can dive into. 

For instance, let's say we want the author information for the first element in the array...

$redditRSS[0].author

Under the author property there are more properties that contain the information we're looking for. To access those, we need to drill down to them.

$redditRSS[0].author.name
$redditRSS[0].author.name.uri

This is part of the discovery you'll want to do when you receive an object back after using Invoke-RestMethod. Dig around, check documentation from the website if there is any, and discover all that you can. There is a lot of information returned, and the more you know, the more things you can do!

Let's dig into the content of the first array element.

$redditRSS[0].content

It looks like we want the '#text' property to see the actual returned content.

$redditRSS[0].content.'#text'

The content we received is in HTML. With some parsing we could make it more readable in the console.

Here's one way to clean it up, by stripping out the HTML tags:

$redditRSS[0].content.'#text' | ForEach-Object { $_ -replace '<[^>]+>','' }

With this object ($redditRSS[0]), we can also get the title and URL to the actual post.

$redditRSS[0].title
$redditRSS[0].link.href

The link value was buried in the href property under link.

What can we do with this information? Well... whatever you need/want to! That's the awesome part about PowerShell. If you need it to do something, there's a way to do it! We could setup a script that watches for a certain post with a specific title, and then have it email you or send you a text. 

You can even make a make-shift post explorer, wherein you can check out the posts and comments of a specific subreddit. I have some example code for doing that as the conclusion to this section.

Code for subreddit exploration:

function Invoke-RedditBrowsing { #Begin function Invoke-RedditBrowsing
    [cmdletbinding()]
    Param(
        [Parameter(Mandatory)]
        [String]
        $subReddit
    )

    #Get the RSS feed for the subreddit requested
    $redditRSS = Invoke-RestMethod "http://www.reddit.com/r/$subReddit.rss"

    #This is a hashtable for the text we'll replace in order to make the comments more readable
    $textToReplace = [hashtable]@{

        '<[^>]+>' = ''
        '&quot;'  = '"'
        '&#39;'   = "'"
        '&#32;'   = ' '

    }

    #Make sure there is content before we proceed
    if ($redditRSS) { #Begin if for $redditRSS existence

        #Use the evil Write-Host to display information on what to do next
        Write-Host 'Select a [#] from the options below!' `n -ForegroundColor Black -BackgroundColor Green

        #Set $i to 0 so our loop iterates and displays $i correctly
        $i = 0 

        #ForEach $post in the subReddit's feed...
        ForEach ($post in $redditRSS) {

            #Use evil Write-Host to display the current value of $i and the title, then the author's name and post updated date
            Write-Host "[$i] -> $($post.Title)"
            Write-Host `t"By: [$($post.author.name)], Updated [$($post.updated)]"`n`n

            #Iterate $i by 1
            $i++

        }

        #Write-Host (new line) to make the next line look prettier
        Write-Host `n

        #Try / Catch to make sure we can convert the input to an integer. If not, we error out
        Try {
        
            #Ask for the post selection so we can proceed
            [int]$selection = Read-Host 'Which [#]? (any invalid option quits)' -ErrorAction SilentlyContinue

        }

        Catch {

            #If we can't make it an int... quit!
            Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }
        
        #Switch statement for $selection. This makes sure it is in bounds for the array of posts.
        Switch ($selection) { #Begin Switch for the $selection value

            #if $i is less than or equal to the total number of values in the array $redditRSS...
            {$_ -le ($i - 1)} { #Begin option for valid selection

                #Get the comments RSS feed from the link provided
                $redditComments = Invoke-RestMethod -Uri "$($redditRSS[$_].link.href).rss"
                
                #Write-Host the title we'll be viewing comments for
                Write-Host `n
                Write-Host "Title: [$($redditRSS[$_].Title)]" -ForegroundColor Black -BackgroundColor Green

                #ForEach comment in the comments feed
                ForEach ($comment in $redditComments) {

                    #Null out anything set in $commentText
                    $commentText = $null

                    #Set the comment text as the property which contains the actual comment
                    $commentText = $comment.Content.'#text'

                    #Go through each key in the hashtable we created earlier
                    ForEach ($text in $textToReplace.Keys) {
            
                        #Re-create the $commentText variable while replace the key ($text), with the value ($textToReplace.$text)
                        #For example: it will match '<[^>]+>', then replace it with ''
                        $commentText = $commentText -replace ($text,$textToReplace.$text) 

                    }

                    #Use Write-Host to write out the author/comment text we cleaned up
                    Write-Host `n`n
                    Write-Host "By: [$($comment.author.name)]" -ForegroundColor Black -BackgroundColor Green
                    Write-Host `t"Comment: [$commentText]"

                }

            } #End option for valid selection

            #If the number does not match a value that is valid, quit!
            Default {

                Write-Host "Invalid option, quitting!" -ForegroundColor Red -BackgroundColor DarkBlue
        
                Break

            }

        } #End Switch for the $selection value

    #If there is nothing in $redditRSS... quit!
    } else {

        Write-Host `n"Unable to get RSS feed for [$subReddit]." -ForegroundColor Red -BackgroundColor DarkBlue

    } #End if $redditRSS

} #End function Invoke-RedditBrowsing

Invoke-RedditBrowsing

Now to run it! It will prompt for the subreddit, which can be any subreddit. I will go with PowerShell.

browse1.PNG

The code will then run, and get the list of posts:

I will choose option [0], which is the first post.

There you have it, one example of what you can do with the data returned via Invoke-RestMethod. The primary motivation for doing something like this, at least for me, is that I like to visualize the data. 

Using Write-Host is not suitable for automation, as it will not be seen by anyone. There are better options as well when you want to make specific things verbose, and others specifically for debug / errors. So why use it? Well, when visualizing the data you have available, it really can help certain bits of text stand out. 

With a visual representation of what's available, it can lead to even more discovery and experimenting, which can in turn lead to more script ideas and ways to handle the data later.

Invoke-RestMethod and APIs

Invoke-RestMethod allows us to work with REST APIs with relative ease. When working with APIs, the most important thing is to familiarize yourself with their documentation. I'll link to the documentation for the different APIs I'll be using here.

Example 1: No Authentication

For this example we will use Weather Underground's Autocomplete API. This API returns a list of locations that best match the input it receives. It can be used in tandem with their full API to get the weather information.

Let's try the following:

$city          = 'Chicago'

$lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

$lookupResults

Looks like we have an object we can explore now! 

$lookupResults | Get-Member

It is a custom object with one property (RESULTS). Let's check it out.

$lookupResults

Quite a few results! We can access the first result by using $lookupResults.RESULTS[0].

The 'l' property is something we will actually use later when getting a forecast for Chicago.

Let's wrap this up in a nice little function.

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

Now we can use (with that function in memory): 

Get-WeatherCity -city Chicago

Example 2: API With a Key

For this example I will use Weather Underground's free API (for developers). To sign up for a key, go here. For the API's documentation, go here.

We'll use the following base code along side this example: 

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

With that function in memory, let's get a returned object for Chicago.

$cityInfo = Get-WeatherCity Chicago

Now for the setup to use the API to get weather information.

This code is one time code to keep my key secure so somebody reading the script can't see it in plain text. 

To do this I will:

  • Set the API key as a secure string in a variable
  • Create a credential object to store the key
  • Export that credential object via Export-Clixml
$apiKey          = 'EnterYourKeyHere' | ConvertTo-SecureString -AsPlainText -Force
$apiCredential   = New-Object System.Management.Automation.PSCredential('apiKey',$apiKey)
$apiCredential | Export-Clixml .\apiCred.xml

With that one time setup out of the way, you can then use:

$apiKey = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password

The key will be available to the script (as plain text), but it is stored in the $apiKey variable.

With all the setup done, here's the block of code we will be running:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL

With the above code executed, $weatherForecast will now contain a plethora of data for us to work with.

$weatherForecast | Get-Member

This is one rich object! For alerts, you can use: $weatherForecast.alerts.

You can do a lot with the information available. I wrote a script that uses it to email a forecast (just the way we want it) to my significant other and myself. I took a parts of the script, and heavily modified them to fit this example. In the following example we will:

  • Setup the API information needed
  • Set a limit on use during the script's execution to 10
  • Declare 3 cities we want the forecast for
  • Create a PowerShell class (PowerShell version 5 only!)
    • This class will have a method that formats the forecast in HTML
  • Lookup the city information
  • Pass the city information to a function that gathers the API data
  • Create an object using the class
  • Loop through the cities, get the information, update the object we created with the class, and export the HTML forecast after using the object's method to create it

Here's the code:

$apiKey          = (Import-Clixml .\apiCred.xml).GetNetworkCredential().Password
$baseURL         = 'http://api.wunderground.com/api/'
$cityInfo        = Get-WeatherCity Chicago
$fullURL         = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($cityInfo.l).json"
$weatherForecast = Invoke-RestMethod -Uri $fullURL
$citiesToGet     = ('Chicago','San Francisco','Portland')
$script:apiLimit = 10

Class Weather { #Begin Weather Class

    [pscustomobject]$apiData

    [string[]]formatForecastHTML() { #Begin method formatForecastHTML

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
            
        if ($curAlerts) {

        foreach ($alert in $CurAlerts) {
            $typeName   = $null
            $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type

            $alertText.Add(@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
        } 

        }    
        
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>
"@

        foreach ($day in $todayForecast) {
   
            $dayImg          = $day.icon_url
            $dayMonth        = $day.date.monthname
            $dayDay          = $day.date.day
            $dayName         = $day.date.weekday
            $dayHigh         = $day.high.fahrenheit  
            $dayLow          = $day.low.fahrenheit
            $maxWind         = $day.maxwind.mph
            $aveWind         = $day.avewind.mph
            $aveHum          = $day.avehumidity
            $conditions      = $day.conditions
            [int]$dayPrecip  = $day.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $dayPrecip
            
            $forecastText.Add(@"
<p></p>
<p></p>
<p><b>$dayName, $dayMonth $dayDay</b></p>
<p><img src=`"$dayImg`">$conditions</p>
<p>Chance of precipitation: $dayPrecip% / $popText</p>
<p>High: $dayHigh`F Low: $dayLow`F</p>
<p>Ave Winds: $aveWind`mph Max Winds: $maxWind`mph</p>
<p>Humidity: $aveHum%</p>
"@) | Out-Null                           

        }

        $body = @"
<p></p>
<p>Here is your 4 day forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@

        Return $body 

    } #End method formatForecastHTML

    [string[]]formatHourlyForecastHTML() { #Begin Class format4DayForecast

        [System.Collections.ArrayList]$alertText    = @()
        [System.Collections.ArrayList]$forecastText = @() 
        $phases                                     = $null
        $todayForecast                              = $this.apiData.forecast.simpleforecast.forecastday
        $city                                       = $this.apiData.current_observation.display_location.full
        $selCam                                     = Get-Random $this.apiData.webcams.count
        $camImg                                     = $this.apiData.webcams[$selCam].CURRENTIMAGEURL
        $camName                                    = $this.apiData.webcams[$selCam].linktext
        $camLink                                    = $this.apiData.webcams[$selCam].link
        $curAlerts                                  = $this.apiData.alerts 
        $hourlyForecast                             = $this.apiData.hourly_forecast
            
        if ($curAlerts) {

            foreach ($alert in $CurAlerts) {
            
                $typeName   = $null
                $typeName   = Get-WeatherInfo -Weather 'alert' -value $alert.type
            
                $alertText.Add(
@"
<p><b><font color=`"red`">Weather Alert! ($typeName)</font></b></p>
<p>Date: $($alert.date) Expires: $($alert.Expires)</p>
<p>$($alert.Message)</p>
"@) | Out-Null              
                            
            } 

        }  
         
        $phases = @"
<p><b>Sun Information</b></p><p></p>
<p>Sunrise: $($this.apiData.sun_phase.sunrise.hour):$($this.apiData.sun_phase.sunrise.minute)</p>
<p>Sunset: $($this.apiData.sun_phase.sunset.hour):$($this.apiData.sun_phase.sunset.minute)</p>
<p></p>
<p><b>Moon Information</b></p><p></p>
<p>Moonrise: $($this.apiData.moon_phase.moonrise.hour):$($this.apiData.moon_phase.moonrise.minute)</p>
<p>Moonset: $($this.apiData.moon_phase.moonset.hour):$($this.apiData.moon_phase.moonset.minute)</p>
<p>Age: $($this.apiData.moon_phase.ageOfMoon) Days</p>
<p>Phase: $($this.apiData.moon_phase.phaseofMoon)</p>
<p>Illumination: $($this.apiData.moon_phase.percentIlluminated)%</p>

"@            
        foreach ($hour in $hourlyForecast) {

            $prettyTime       = $hour.fcttime.pretty
            $hourTemp         = $hour.temp.english  
            $hourImg          = $hour.icon_url
            $hourChill        = $hour.windchill.english
            if ($hourChill -eq -9999) {
                
                $hourChill = 'N/A'
                    
            } else {
                    
                $hourChill = $hourChill + 'F'
                
            }

            $hourWind         = $hour.wspd.english
            $windDir          = $hour.wdir.dir
            $hourUV           = $hour.uvi
            $dewPoint         = $hour.dewpoint.english
            $hourFeels        = $hour.feelslike.english
            $hourHum          = $hour.humidity
            $conditions       = $hour.condition
            [int]$hourPrecip  = $hour.pop
                
            $popText = Get-WeatherInfo -Weather 'preciptext' -value $hourPrecip
            $forecastText.Add(
@"
<p></p>
<p></p>
<p><b>$prettyTime</b></p>
<p><img src=`"$hourImg`">$conditions</p>
<p>Chance of precipitation: $hourPrecip% / $popText</p>
<p>Current Temp: $hourTemp`F Wind Chill: $hourChill Feels Like: $hourFeels`F</p>
<p>Dew Point: $dewPoint</p>
<p>Wind Speed: $hourWind`mph Direction: $windDir</p>
<p>Humidity: $hourHum%</p>
<p>UV Index: $hourUV     
"@) | Out-Null                

        }

        $body = 
@"
<p></p>
<p>Here is your hourly forecast!</p>
<p>Random webcam shot from: <a href=`"$camLink`">$camName</a></p>
<p><img src=`"$camImg`"></p>
<p>$city Radar:</p>
<p><img src=`"$($this.radarURL)`"></p>
$($phases       | Out-String)
$($alertText    | Out-String)
$($forecastText | Out-String)
"@
        return $body 

    } #End method formatHourlyForecastHTML

    Weather([String]$apiURL, [String]$radarURL, [PSCustomObject]$apiData) {
        
        $this.radarURL = $radarURL
        $this.apiData  = $apiData

    }

    Weather() {}

} #End class Weather

function Get-WeatherCity { #Begin function Get-WeatherCity
    [cmdletbinding()]
    param(
        [parameter(Mandatory=$true)]
        [string]
        $city
    )

    Try {
        
        $lookupResults = Invoke-RestMethod -Uri "http://autocomplete.wunderground.com/aq?query=$city"

    }

    Catch {
        
        $errorMessage = $_.Exception.Message

        Write-Host "Error looking up [$city]: [$errorMessage]"

        Return

    }

    if ($lookupResults.results) {

        Return $lookupResults.Results[0]

    } else {

        Write-Host "Unable to find a result for: [$city]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
        
} #End function Get-WeatherCity

function Get-WeatherAPIData { #Begin function Get-WeatherAPIData
    [cmdletbinding()]
    param(
        [parameter(Mandatory)]
        [PSCustomObject]
        $city
    )
    
    [System.Collections.ArrayList]$weatherCity = @()
    $weatherProperty                           = $null
    $weatherO                                  = $null

    $fullURL  = $baseURL + $apiKey + '/features/conditions/hourly/forecast/webcams/astronomy/alerts' + "$($city.l).json"
    $radarURL = "http://api.wunderground.com/api/$apiKey/animatedradar/animatedsatellite" + "$($city.l).gif?num=6&delay=50&interval=30"

    Try {

        if ($script:apiCount -le $apiLimit) {

            Write-Host "Attempting to get API information for: [$($city.Name)]."  
                  
            $weatherForecast = Invoke-RestMethod -Uri $fullURL 
                
            $script:apiCount++

            $weatherForecast | Export-Clixml ("$outputDir\$($city.Name)APIData{0:MM-dd-yy_HHmm}.clixml" -f (Get-Date)) 

            Write-Host "The API has been used [$script:apiCount] times, against the limit of [$apiLimit]"

        } else {

            Write-Host "The API limit of [$apiLimit] has been reached!" -ForegroundColor Red -BackgroundColor DarkBlue

            Break

        }

    }

    Catch {

        $errorMessager = $_.Exception.Message 

        Write-Host "Unabled to get API data from Weather Underground: [$($_.Exception.Message)]." -ForegroundColor Red -BackgroundColor DarkBlue

    }

    if ($weatherForecast.forecast) {

        Write-Host "API information found for: [$($city.Name)]"

        $weatherO = [PSCustomObject]@{

            APIData  = $weatherForecast
            RadarURL = $radarURL 

        }

        $weatherCity.Add($weatherO) | Out-Null

        Return $weatherCity

    } else {

        Write-Host "API information not found for: [$($city.Name)]" -ForegroundColor Red -BackgroundColor DarkBlue

    }
  
} #End function Get-WeatherAPIData

function Get-WeatherInfo { #Begin function Get-WeatherInfo
    [cmdletbinding()]
    param(
        [string]
        $weather,
        $value       
    )
    
    Switch ($weather) {
        
        {$_ -eq 'preciptext'} {
        
            Switch ($value) {
                
                {$_ -lt 20} {
                    
                    $popText = 'No mention'
                    
                }
                
                {$_ -eq 20} {
                    
                    $popText = 'Slight Chance'
                    
                }
                
                {($_ -lt 50 -and $_ -gt 20)} {
                    
                    $popText = 'Chance'
                    
                }
                
                {$_ -eq 50} {
                    
                    $popText = 'Good chance'
                    
                }
                
                {($_ -lt 70 -and $_ -gt 50)} {
                    
                    $popText = 'Likely'
                    
                }
                
                {$_ -ge 70} {
                    
                    $popText = 'Extremely likely'
                    
                }
                
            }
            
            Return $popText
            
        }   
        
        {$_ -eq 'alert'}    {
            
            Switch ($value) {
                
                'HEA' {$typeName = 'Heat Advisory'}
                'TOR' {$typeName = 'Tornado Warning'}
                'TOW' {$typeName = 'Tornado Watch'}
                'WRN' {$typeName = 'Severe Thunderstorm Warning'}
                'SEW' {$typeName = 'Severe Thunderstorm Watch'}
                'WIN' {$typeName = 'Winter Weather Advisory'}
                'FLO' {$typeName = 'Flood Warning'}
                'WAT' {$typeName = 'Flood Watch / Statement'}
                'WND' {$typeName = 'High Wind Advisory'}
                'SVR' {$typeName = 'Severe Weather Statement'}
                'HEA' {$typeName = 'Heat Advisory'}
                'FOG' {$typeName = 'Dense Fog Advisory'}
                'SPE' {$typeName = 'Special Weather Statement'}
                'FIR' {$typeName = 'Fire Weather Advisory'}
                'VOL' {$typeName = 'Volcanic Activity Statement'}
                'HWW' {$typeName = 'High Wind Warning'}
                'REC' {$typeName = 'Record Set'}
                'REP' {$typeName = 'Public Reports'}
                'PUB' {$typeName = 'Public Information Statement'}
                    
            }
                
            Return $typeName
    
        }    
    }
    
} #End function Get-WeatherInfo

$weatherObject = [Weather]::New()

Write-Host `n`n

ForEach ($cityName in $citiesToGet) {

    $findCity    = Get-WeatherCity -city $cityName
    $weatherCity = Get-WeatherAPIData -city $findCity -ErrorAction Stop    

    if ($weatherCity) {

        $weatherObject.apiData  = $null
        $weatherObject.apiData  = $weatherCity.APIData

        $foreCastFile = $null
        $foreCastFile = ".\$cityName.html"

        Write-Host `n"Exporting HTML formatted forecast to [$foreCastFile]."`n

        $weatherObject.formatHourlyForecastHTML() | Out-File ".\$cityName.html"

    } else {

        Write-Host "No data in [`$weatherCity] variable, halting execution on [$($userEmail.FirstName) $($userEmail.LastName)]." -ForegroundColor Red -BackgroundColor DarkBlue
                
        Break

    }

}

Write-Host `n`n

There should be 3 files in the folder now.

There they are. Let's take a look at Chicago's forecast.

You can do anything you put your mind to with the data received.

Homework

  • Explore the class created here to see how the information in the HTML generated forecast is created.
    • A lot of this information is transferable to functions and other methods of creating the HTML formatted forecast if you do not have PowerShell version 5.
  • See if a web service you use often has an API, read up on the documentation, and see if there's something you can automate doing with PowerShell!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

PowerShell: Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Getting Started - Utilizing the Web: Part 1 (Invoke-WebRequest)

Welcome to my Getting Started with Windows PowerShell series!

Why Work With the Web in PowerShell?

PowerShell can do quite a bit with web sites, and web services. 

Some of what we can do includes:

  • Checking if a website is up
  • Downloading files
  • Logging in to webpages
  • Parsing the content we receive
  • Utilizing REST methods

Learning how to use web services with PowerShell can expand your realm of possibility when scripting.

Using Invoke-WebRequest

Invoke-WebRequest is a command that allows us to retrieve content from web pages. The methods supported when using Invoke-WebRequest are:

  • Trace
  • Put
  • Post
  • Options
  • Patch
  • Merge
  • Delete
  • Default
  • Head
  • Get (Default)

Let's take a look at some different ways to utilize Invoke-WebRequest

Downloading a File

Let's download a file! In this particular example, we will download an addon for World of Warcraft.

The page we'll be looking at is http://www.tukui.org/dl.php.

In particular, let's get the ElvUI download.

PowerShell setup:

$downloadURL     = 'http://www.tukui.org/dl.php'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL

Here I set the $downloadURL variable to store the main page we want to go to, and then use $downloadRequest to store the results of Invoke-WebRequest (using the $downloadURL as the URI).

Let's take a look at what's in $downloadRequest.

We'll go over some of the other properties later, and focus on Links for now. The Links property returns all the links found on the web site. $downloadRequest.Links

The list continues, but you get the idea. This is one of my favorite things about the Invoke-WebRequest command. The way it returns links is very easy to parse through.

Let's hunt for the ElvUI download link. To do that we'll pipe $downloadRequest.Links to Where-Object, and look for links that contain a phrase like Elv and Download.

$downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}

Got it! Now let's store that href property in a variable.

$elvLink         = ($downloadRequest.Links | Where-Object {$_ -like '*elv*' -and $_ -like '*download*'}).href

That variable will now contain the download link.

Next we'll use Invoke-WebRequest again to download the file. There are two ways we can get the file:

  • Using Invoke-WebRequest to store the results in a variable,  and then write all the bytes to a file using the Contents property (which is a byte array).
  • Using Invoke-WebRequest with the -OutFile parameter set as the full path of the download. With this option we'll want to use -PassThru so we can still get the results from Invoke-WebRequest (otherwise success = empty result/no object returned).

Using the Contents property and writing the bytes out

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)
$downloadRequest = Invoke-WebRequest -Uri $elvLink 
$fileContents    = $downloadRequest.Content

The above code takes the link we stored, and gets the file name from it using LastIndexOf, and SubString.

It then stores the download request results in $downloadRequest

Finally, we get the contents (which is a byte array, if all went well), and store that in $fileContents.

The last thing we'll need to do is write the bytes out. To do that we'll use [io.file]WriteAllBytes(path,contents) to write the byte array to a file.

[io.file]::WriteAllBytes("c:\download\$fileName",$fileContents)

Let's run the code now, and see what happens!

Now we should have a file in "C:\download\"...

There it is! 

The $downloadRequest variable stores the results from the request, which you can use to validate accordingly. 

Using Invoke-WebRequest with -OutFile

Another way to download the file would be to use the -OutFile parameter with Invoke-WebRequest. We'll want to set the filename first:

$fileName        = $elvLink.Substring($elvLink.LastIndexOf('/')+1)

This is the same code from the previous example, and it uses LastIndexOf, and SubString.

Here's the code to download the file:

$downloadRequest  =  Invoke-WebRequest -Uri $elvLink -OutFile "C:\download\$fileName" -PassThru

Note that we used -PassThru as well. That is so we can still see the results of the request in the variable $downloadRequest. Otherwise a successful result would return no object, and your variable would be empty.

Let's see if that worked!

It did, and it was a bit easier than the previous example.

Downloading Files With a Redirect

Let's take a look at downloading files from sites that have a redirect. For this example I will use downloading WinPython from https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4. Note: Invoke-WebRequest is typically great at following redirected links. This example is here to show you how to retrieve redirected links, as well as a cleaner way to get the file.

Here is what a typical page that has a redirected download looks like:

Invoke-WebRequest includes a parameter that will force the maximum number of times it will accept redirection (-MaximumRedirection). We'll set that to 0, which will error it out, and use -ErrorAction SilentlyContinue to allow the error to be ignored. I will also use the parameter -UserAgent to send along the user agent string for FireFox. If we do not do this, the download will not work on this website.

Here's the initial code:

I set the URL we want to download from and store it in $downloadURL

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'

Then I store the result of our Invoke-WebRequest command in $downloadRequest.

$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue

Let's take a closer look at the Invoke-WebRequest line.

  • -MaximumRedirection 0
    • This ensures we do not get automatically redirected. Setting it to 0 forces no re-direction, and allows us to manually collect the redirection data.
  • -ErrorAction SilentlyContinue
    • This tells PowerShell to ignore the redirection error message. The downside to this is that it will be hard to capture any other errors. This is the only way I was able to find that keeps information in the variable $downloadRequest.
  • -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
    • This sends the user agent string for FireFox along in the request. We can see what [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox resolves to by simply typing(or pasting) it in the console.

Now we have some helpful information in our $downloadRequest variable, assuming all went well.

$downloadRequest.StatusDescription should be "Found".

Good! The redirect link is stored in the header information, accessible via $downloadRequest.Headers.Location.

I did some digging in the Content property, and found the string that matches the file name. I then added some code for the $fileName variable that looks for a string that matches the file name, and selects the matched value.

$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Now that we have this information, we're ready to continue! I used a couple Switch statements to add some logic, in case the responses aren't what we expected.

Here's the full code for this example:

$downloadURL     = 'https://sourceforge.net/projects/winpython/files/latest/download?source=frontpage&position=4'
$downloadRequest = Invoke-WebRequest -Uri $downloadURL -MaximumRedirection 0 -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox -ErrorAction SilentlyContinue
$fileName        = (Select-String -Pattern 'WinPython-.+exe' -InputObject $downloadRequest.Content -AllMatches).Matches.Value

Switch ($downloadRequest.StatusDescription) {

    'Found' {
        
        Write-Host "Status Description is [Found], downloading from redirect URL [$($downloadRequest.Headers.Location)]."`n
        $downloadRequest = Invoke-WebRequest -Uri $downloadRequest.Headers.Location -UserAgent [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox 

    }

    Default {

        Write-Host "Status Descrption is not [Found], received: [$($downloadRequest.StatusDescription)]. Perhaps something went wrong?" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Switch ($downloadRequest.BaseResponse.ContentType) {

    'application/octet-stream' {

        Write-Host 'Application content type found!'`n
        Write-Host "Writing [$fileName] to [C:\download]"`n

        [io.file]::WriteAllBytes("c:\download\$fileName",$downloadRequest.Content)

    }

    Default {

        Write-Host "Content type is not an application! Not extracting file." -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

The first Switch statement ensures that the StatusDescription is "Found", then sets $downloadRequest as the result of the Invoke-WebRequest command that now points to the redirect URL. If the StatusDescription is not found, you'll see a message stating that something went wrong.

We then use a Switch statement that ensures our downloaded content (in $downloadRequest) has the Content Type of "application/octet-stream". If it is, we write the file out using [io.file]WriteAllBytes(path,contents).

Let's run the code, and then look in "C:\download\" to verify the results!

While it downloads, this progress indicator is displayed (Sometimes it will not match the actual progress):

Looks like everything worked! One last place to check.

We got it. All 277MB downloaded and written to the appropriate location.

Parsing Content

Using Invoke-WebRequest, the content of the request is returned to us in the object. There are many ways to go through the data. In this example I will demonstrate gathering the titles and their associated links from the PowerShell subreddit.

Here's the setup:

$parseURL    = 'http://www.reddit.com/r/powershell'
$webRequest  = Invoke-WebRequest -Uri $parseURL

Now let's take a look at the $webRequest variable.

  • RawContent
    • This is the content returned as it was received, including header data. 
  • Forms
    • This property contains any discovered forms. We'll go over this portion in more detail when we log in to a website.
  • Headers
    • This property contains just the returned header information.
  • Images
    • This property contains any images that were able to be discovered.
  • InputFields
    • This property returns discovered input fields on the website.
  • Links
    • This returns the links found on the website, in an easy format to iterate through.
  • ParsedHTML
    • This property allows you to access the DOM of the web site. DOM is short for Document Object Model. Think of DOM as a structured representation of the data on the website.

As always, if you want to see what other properties were returned, and any methods available, pipe $webRequest to Get-Member.

As you can see there are a few more properties that exist, but we'll be focusing on the ones described above in this article.

Now to get the title text from the current posts at http://www.reddit.com/r/powershell.

The fastest way to narrow it down, is to launch a browser and take a look at the DOM explorer. In Edge I used [F12] to launch the developer tools, and then used the [Select Element] option in the [DOM Explorer] tab. I then selected one of the posts to see what it looked like. 

It looks like the link is under a class named title, and the tag <p>. 

Let's use the ParsedHTML property to access the DOM, and look for all instances of <p> where the class is title.

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'}

The results should look similar to this (quite a lot of text will scroll by):

To verify if this is just the title information, let's pipe the above command to | Select-Object -ExpandProperty OuterText

$webRequest.ParsedHTML.getElementsByTagName('p') | Where-Object {$_.ClassName -eq 'title'} | Select-Object -ExpandProperty OuterText

Awesome, looks like what we want to see!

Let's store all those titles (minus the (text)) at the end, in $titles.

The biggest problem I encountered was just getting the title names (minus the text after at the very end such as: (self.PowerShell)), while also not omitting results that had (text) in other places. Here is the solution I came up with to store all the post titles in the variable $titles.

$titles      = $webRequest.ParsedHTML.getElementsByTagName('p') | 
               Where-Object {$_.ClassName -eq 'title'}          |                    
               ForEach-Object {
               
                    $splitTitle = $null
                    $splitCount = $null
                    $fixedTitle = $null

                    $splitTitle = $_.OuterText.Split(' ')
                    $splitCount = $splitTitle.Count

                    $splitTitle[($splitCount - 1)] = $null

                    $fixedTitle = ($splitTitle -join ' ').Trim()

                    Return $fixedTitle

               }

In the above command, I piped our title results to ForEach-Object, and then used some string manipulation to split the title into an array, null out the last entry in the array, join the array back, and finally trim it so there is no extra white space.

Now let's take a look at our $titles variable.

Perfect! The next step is matching the titles up with the property Links in our $webRequest variable. Remember that $webRequest.Links contains all the links on the web site.

After some digging, I found that the link property of outerText matches the titles in our $titles variable. Now we can iterate through all the titles in $titles, find the links that match, and create a custom object to store an index, title, and link.

We will need to do some more string manipulation to get the link out of the outerHTML property. It's all doable, though! 

Finally, we'll store the custom object in an array of custom objects. 

Here is the full code (with comments to explain what is happening):

#Ensure i is set to 0 so it iterates properly in the loop below
$i = 0
#Create the .Net ArrayList object to store our custom objects
[System.Collections.ArrayList]$prettyLinks = @()
#Iterate through all the titles
ForEach ($title in $titles) {

    #Search through the Links property of $webRequest and take action where the outerText equals the title
    #For each of the found requests (each link that matches)...
    $webRequest.Links  | Where-Object {$_.outerText -eq $title} | ForEach-Object {
    
    #Null out variables used in this loop
    $linkObject = $null
    $linkURL    = $null
    $titleText  = $null

    #Set the link URL as $link using the outerHTML property of the Link
    $link       = $_.outerHTML
    #Set the title text as $titleText using the outerText property of the Link
    $titleText  = $_.outerText

    #Split the link where there are spaces, and keep the part that matches href
    $link       = $link.Split(' ') | Where-Object {$_ -match '^href'}
    #Manipulate the link to get just the url, and trim and extra quotes
    $link       = $link.SubString($link.IndexOf('"')).Trim('"')

    #If the link is a link to an external source (starts with http:// or https://), then it is a full link, set $linkURL to $link
    if ($link -match '^https?://.+') {

        $linkURL = $link
    
    #Else it is a link to this subreddit as a self post, $linkURL prepends 'http://www.reddit.com' to the $link (which is /r/powershell/...)
    } else {

    
        $linkURL = ('http://www.reddit.com' + $link)

    }
    
    #Create a custom object to store the index ($i), the title, and link
    $linkObject = [PSCustomObject]@{

        Index = $i
        Title = $titleText
        Link  = $linkURL

    }

    #Add the object to our object array
    $prettyLinks.Add($linkObject) | Out-Null

    #Add 1 to $i
    $i++    
   
    }  

}

Let's run the code, and then take a look at our $prettyLinks variable.

That looks good, and the object is at our disposal for whatever we'd like to do with the information.

For an example on how the code can be used, check this out!

$browsing = $true

While ($browsing) {

    $selection = $null

    Write-Host "Select a [#] from the titles below!"`n -ForegroundColor Black -BackgroundColor Green

    ForEach ($pretty in $prettyLinks) {

        Write-Host "[$($pretty.Index)] $($pretty.Title)"`n

    }

    Try {
        
        [int]$selection = Read-Host 'Which [#]? "q" quits'

        if ($selection -le ($prettyLinks.Count -1)) {

            Start-Process $prettyLinks[$selection].Link 

        } else {

            $browsing = $false

            Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue
            
        }

    }

    Catch {

        $browsing = $false

        Write-Host '"q" or invalid option selected, browsing aborted!' -ForegroundColor Red -BackgroundColor DarkBlue        

    }

}

The above code creates a loop until the user inputs "q", or an invalid option. It will list out all of the titles, and then ask you for the number of the one you want to look at. Once you input the number, it will launch your default browser to the title's associated link.

This is but one of many examples of how the data can be used. Check out these screenshots to see the code in action.

Let's select 17.

Here's what happens if you put "q".

Working With Forms

Invoke-WebRequest can also work with form data. We can get the forms from the current request, manipulate the data, and then submit them.

Let's take a look at a simple one, searching Reddit.

$webRequest = Invoke-WebRequest 'http://www.reddit.com'

Let's take a look at $webRequest.Forms:

Now that we know that the search form is the first array value, let's declare $searchForm as $webRequest.Forms[0].

$searchForm = $webRequest.Forms[0]

Now $searchForm will contain the form we care about.

Here are the properties we see at a glance:

  • Method
    • This is the method we'll use when sending the request with the form data.
  • Action
    • This is the URL used with the request. Sometimes it is the full URL, other times it is part of a URL that we need to concatenate together with the main URL.
  • Fields
    • This is a hash table that contains the fields to be submitted in the request.

Here are the values in $searchForm.Fields

Let's set the value of "q" to what we'd like to search for. I will set it to: "PowerShell".

$searchForm.Fields.q = 'PowerShell'

It's always good to verify things are as they should be. 

$searchForm.Fields

That looks good! Now to format our next request, and search Reddit!

$searchReddit = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

In this request, the following parameters are set:

  • -Uri
    • We use $searchForm.Action  for this as that contains the full URL we need to use.
  • -Method
    • We use $searchForm.Method for this. Technically it would default to using Get, but that is not the case for all forms. It is good to use what the form states to use.
  • -Body
    • We use $searchForm.Fields for this, and the body is submitted as the hash table's key/value pairs. In this case that is "q" = "PowerShell". 

Now that we have the results in $searchReddit, we can validate the data by taking a look at the links.

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Now that we've validated it worked, you could also parse the contents to get what you want out of it!

Full code for this example:

$webRequest          = Invoke-WebRequest 'http://www.reddit.com'
$searchForm          = $webRequest.Forms[0]
$searchForm.Fields.q = 'PowerShell'
$searchReddit        = Invoke-WebRequest -Uri $searchForm.Action -Method $searchform.Method -Body $searchForm.Fields

$searchReddit.Links | Where-Object {$_.Class -eq 'search-title may-blank'} | Select-Object InnerText,Href

Logging In To Web Sites

We can also use Invoke-WebRequest to log in to web sites. To do this we'll need to be sure to do the following:

  • Set the userAgent to Firefox.
    • This may not be required, but it is generally safe to do.
  • Use the sessionVariable parameter to create a variable.
    • This will be used to maintain the session, and store cookies.
  • Populate the correct form with login details.
    • We'll store the credentials in $credential via Get-Credential.

We'll start by storing my credentials for Reddit in $credential, setting $uaString to the FireFox user agent string, and finally using Invoke-WebRequest to initiate our session.

$credential = Get-Credential
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

!!NOTE!! When setting the parameter -SessionVariable, do not include the "$" in the variable name.

$webRequest.Forms contains all the forms.

The Id of the form we need is "login_login-main". Knowing this, we can use the following line to get just the form we need:

$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}

Now to check $loginForm.Fields to be sure it is what we need, and to see what the properties we need to set are.

Let's set the fields "user" and "passwd" using the $credential variable we created earlier.

$loginForm.Fields.user   = $credential.UserName
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

!!NOTE!! The $loginForms.Fields.passwd property will store the password as plain text.

Alright! Now that our setup is complete, we can use the following command to attempt to log in:

$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

This request contains the following information:

  • -Uri $loginForm.Action
    • This uses the URL provided in the property Action from the login form.
  • -Method $loginForm.Method
    • This uses the Method provided in the property Method from the login form.
  • -Body $loginForm.Fields
    • This sends along the hash table (which includes our username and password) along with the request.
  • -WebSession $webSession
    • This tells Invoke-WebRequest to use the SessionVariable we created for cookie storage. We use $webSession this time, as the initial request was creating the variable, and we are utilizing it now.
  • -UserAgent $uaString
    • This sends along the FireFox user agent string we set earlier.

We can now use the following code to verify if we've succeeded in logging in:

if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

It worked! This verification check works by doing a wildcard search for the username that is stored in the credential object $credential in any of the web site's links.

Now that you have an authenticated session, you can browse/use Reddit with it by using the parameter -WebSession, and the value $webSession.

Full code for this example:

#Store the credentials you are going to use!
#Note: if you want to securely store your credentials, you can use $credential | Export-CliXML .\credential.xml
#Then you can import it by using $credential = Import-CliXML .\credential.xml
$credential = Get-Credential
#Set $uaString as the FireFox user agent string
$uaString   = [Microsoft.PowerShell.Commands.PSUserAgent]::FireFox
#Store the initial WebRequest data in $webRequest, create the session variable $webSession
$webRequest = Invoke-WebRequest -Uri 'www.reddit.com' -SessionVariable webSession -UserAgent $uaString

#Gather and set login form details
$loginForm               = $webRequest.Forms | Where-Object {$_.Id -eq 'login_login-main'}
$loginForm.Fields.user   = $credential.UserName
#NOTE: This will store the password in the hash table as plain text
$loginForm.Fields.passwd = $credential.GetNetworkCredential().Password

#Attempt to log in using the Web Session $webSession, with the information provided in $loginForm
$webRequest = Invoke-WebRequest -Uri $loginForm.Action -Method $loginForm.Method -Body $loginForm.Fields -WebSession $webSession -UserAgent $uaString

#Validate if the login succeeded, then take action accordingly.
if ($webRequest.Links | Where-Object {$_ -like ('*' + $credential.UserName + '*')}) {

    Write-Host "Login verified!"

} else {

    Write-Host 'Login unsuccessful!' -ForegroundColor Red -BackgroundColor DarkBlue

}

Homework

  • What error checking could we have applied to some of these examples?
  • Think of things you could automate when using Invoke-WebRequest that you use a browser for every day.
  • What better way could we have validated the data in the final example in the parsing data section?
  • How could we prompt the user for input as to what to search Reddit for?

Keep an eye out for Parts 2 and 3, coming in the next couple weeks!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Importing, Using, and Exporting Data

PowerShell: Getting Started - Importing, Using, and Exporting Data

Getting Started - Importing, Using, and Exporting Data

Updated to reflect changes in what list type you should use.

Example:

Do use:

[Collections.Generic.List[PSCustomObject]]$resultsArray = @()

Don’t use:

[System.Collections.ArrayList]$resultsArray = @()

The reasoning behind this can be read, here: https://docs.microsoft.com/en-us/dotnet/api/system.collections.arraylist?view=netframework-4.8

Importing Data Into PowerShell

PowerShell offers a variety of ways to import data. I will be going over just a few of the ways in this article, but a lot of the concepts stay the same no matter which way you need to use. Remember that at the end of the day, the best way is the one that gets the job done for you!

The general guideline I follow is:

  • Import the data

  • Validate the data

  • Iterate through the data and take actions

  • Export data when needed

Text Files

While this method isn't as structured as others, it can really be handy in a pinch. You can copy and paste a list of users, computers, or anything else you want to iterate through, and then save it as a text file. Once you have a file, you can use PowerShell to import the contents.

For this example, I will import the contents of Cities.txt, seen here:

I'll use Get-Content to store the contents of the file in the variable $Cities.

$Cities = Get-Content .\Cities.txt

Let's take a look at the $Cities variable.

The contents have been successfully imported. We can see what type of object we're working with by piping $Cities to Get-Member.

$Cities | Get-Member

Looks like we're working with an array of strings. We can see this by using the Count property, as well as taking a look at the first value in the array, which would be reflected as $Cities[0].

CSV Files

PowerShell works nicely with CSV files. This method of importing data is a bit more structured, and overall is very clean. The headers of the CSV file will be imported as properties, and we'll take a look at that after we import the contents. 

For this example I will import a CSV file that contains a list of users that HR wants us to create in our environment. I will use the command Import-CSV, and store the contents in the variable $Users.

Here is what the CSV file looks like:

Let's go ahead and import the CSV now.

$Users = Import-CSV .\ADUsers.csv

Now let's see what $Users contains.

Awesome! We even have some properties now.  Let's see what we have when we pipe $Users to Get-Member.

Looks like CSVs are imported into a custom object, which we can then use as needed!

We can use the Count property to get the number of users imported, as well as take a look at the first value ($Users[0]) of the array. 

CliXML Files

CliXML files in PowerShell are a special type of XML file that represent object(s) that have been exported. This is extremely handy when you need to export an object that you'll later want to import into PowerShell.  You are also able to export information as CliXML that contains credentials, securely. 

Using CliXML is the best way to export/import objects that you are going to use directly in PowerShell.

For this example, I will import a CliXML file I exported, which contains the results of Get-Process.

Here is the file:

Another benefit of CliXML is it maintains hierarchies. Let's import this content into the variable $Processes, via Import-Clixml.

$Processes = Import-Clixml .\Processes.xml

Success! We can see the objects stored in the $Processes variable.

Let's pipe $Processes to Get-Member and see what we have.

$Processes | Get-Member

Our object TypeName will have the saved object type appended to "Deserialized", and in our case that is "Deserialized.System.Diagnostics.Process".

We can use the Count property to get the number of objects in $Processes, as well as look at the first value by using $Processes[0].

Validation

After we import the information into PowerShell, validating it is the next important step. I went over some basic examples in my article on error handling. In this article we will be going further into how to validate data.

Text Files

There are a few different ways to validate information in text files. Using our example of importing cities from above, here is what our $Cities variable contains:

What problems can you spot with this? I see two! 

  • $3477|3 is not a city

  • There seem to be some blank spaces at the end (Carriage returns)

Blank spaces can do some not-so-awesome things in scripts.

The easy fix for this script, since we're looking for city names, is to use the following regular expression, with the -match comparison operator: [a-z]. If you're not familiar with regular expressions, they can seem confusing at first. This basically says match any characters a-z. 

Visit http://regexone.com/ to learn more about Regex, I've found it to be a great resource.

Let's re-declare our variable, using itself, Where-Object, and -match

$Cities = $Cities | Where-Object {$_ -match '[a-z]'}

Now let's see the contents of $Cities.

That looks much better!

CSV/CliXML

Validating data from importing CSVs or CliXML files is a similar process. Since we'll have properties to work with, and values for each of those properties, the logic can become a bit more complex.

One way to handle it would be to not handle it up front, but to use some error handling, and catch any errors if they occur due to incomplete/invalid data. 

While that will technically work... you can catch invalid or incomplete data another way. 

In this example we'll be using the CSV file from earlier, and it's contents (with one addition, a test user with some missing information).

Here's our imported test data:

I'll now use the following code to validate the data:

#Iterate through each user
ForEach ($user in $users) {

    #Declaring variables up top
    $userProperties = $null
    $skipUser       = $null

    #Setting the $userProperties variable to contain the properties of the current $user object
    $userProperties = $user | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name
    #Setting $skipUser to $false by default 
    $skipUser       = $false

    #Now we will iterate through each property
    ForEach ($property in $userProperties) {
    
        #For the current $property, we're using a switch statement to evaulute the $user object's current value
        #We do this by accessing $user.$property
        Switch ($user.$property) {

            #If there are any non-alphanumeric characters, minus spaces, set $skipUser to $true
            #We then break out of the switch
            {$_ -match '\W' -and $_ -notmatch '\s'} {
        
                $skipUser = $true

                Break

            }

            #If any of the values are blank, set $skipUser to $true
            #We then break out of the switch
            {$_ -eq ''} {

                $skipUser = $true

                Break

            }

        }
        
    }

    #If $skipUser is $false, perform this action
    if (!$skipUser) {

        Write-Host "Processing $($user | Out-String)"
        #Processing code here

    #Else (basically if $skipUser is $true), perform this action
    } else {

        Write-Host "Skipping $($user | Out-String)"
        #Skipping code here

    }

}

I use a couple different loops, a switch statement, regex, and an if statement to validate the data. I've added comments to the above code so you can better understand what is happening.

Let's take a look at the results:

Perfect! The two that we'd expect to be skipped, are indeed skipped.

Iterating Through Data

Iterating through data in PowerShell typically involves using loops. While I have gone over loops in a previous article, I will cover how they apply to our examples above.

Text Files

Let's expand upon our example from validating Text Files above. We have the following cities in $Cities:

Let's iterate through them, and just for fun, return some information from Weather Underground's free autocomplete API for each one.

[Collections.Generic.List[PSCustomObject]]$apiDataArray = @()

ForEach ($city in $Cities) {

    $apiURL         = $null
    $cityName       = $null
    $apiData        = $null
    $cityDataObject = $null

    $cityName       = $city
    $apiURL         = "http://autocomplete.wunderground.com/aq?query=$cityName"
    $apiData        = Invoke-RestMethod -Uri $apiURL

    $cityDataObject = [PSCustomObject]@{

        CityName = $cityName
        APIData  = $apiData

    }

    $apiDataArray.Add($cityDataObject) | Out-Null

}

In the above example I use a ForEach loop, Invoke-RestMethod, and a PS Custom Object.

Let's take a look at the results in $apiDataArray!

We now have an array of cities, and their associated API data. Let's dig into the data for Chicago.

Hmm, looks like we'll need to access the RESULTS property.

The API returned the results in an array for what it found when we gave it the value of Chicago for the city name. The first object in the array is the closest match. Let's access that!

There we go, data we can use.

CSV/CliXML Files

We'll be using the CSV information we validated earlier in this example. We pretty much demonstrated iterating through it when we validated it. However, I'd like to take that example further to demonstrate a few things, and put a lot of the concepts I've been going over together!

For this example I will iterate through the contents of the CSV file, and perform the following actions:

  • Create an array to store objects in

  • Validate information

  • Attempt to create the user in my Active Directory lab environment

  • Capture all results in an object

  • Add the object to the array

  • Return the object array

Here is the code (I've added comments so you can understand what is happening, every step of the way):

#Import users and create an array to store the results in
$Users                                                  = Import-CSV .\ADUsers.csv
[Collections.Generic.List[PSCustomObject]]$resultsArray = @()

#if $Users exists
if ($Users) {

    #Iterate through each user
    ForEach ($user in $users) {

        #Declaring variables up top
        $userProperties = $null
        $skipUser       = $null
        $samAccountName = $null
        $createUser     = $null
        $userName       = $null
        $resultsObject  = $null
        $csvValidation  = $null

        #Setting the $userProperties variable to contain the properties of the current $user object
        $userProperties = $user | Get-Member -MemberType NoteProperty | Select-Object -ExpandProperty Name

        #Setting $skipUser to $false by default 
        $skipUser       = $false

        #Now we will iterate through each property
        ForEach ($property in $userProperties) {
    
            #For the current $property, we're using a switch statement to evaulute the $user object's current value
            #We do this by accessing $user.$property
            Switch ($user.$property) {

                #If there are any non-alphanumeric characters, minus spaces, set $skipUser to $true
                #Set $csvValidation to the property we found an issue with
                #We then break out of the switch
                {$_ -match '\W' -and $_ -notmatch '\s'} {
        
                    $skipUser      = $true
                    $csvValidation = "Invalid characters in [$property]"

                    Break

                }

                #If any of the values are blank, set $skipUser to $true
                #Set $csvValidation to the property we found an issue with
                #We then break out of the switch
                {$_ -eq ''} {

                    $skipUser      = $true
                    $csvValidation = "Empty value for [$property]"

                    Break

                }

            }
        
        }

        #If $skipUser is $false, perform this action
        if (!$skipUser) {
        
            #Set the name of the user in $userName using first and last name from CSV
            $userName = "$($user.'First Name') $($user.'Last Name')"

            #Set $adServer with the hostname of a domain controller
            $adServer = (Get-ADDomainController).HostName

            Write-Host "Creating AD account for $userName..."`n

            #Set $samAccountName variable with first initial+lastName.
            $samAccountName = ($user.'First Name'.Substring(0,1) + $user.'Last Name').ToLower()
     
            Try {

                #Store created AD User object results in $createUser via New-ADUser with -PassThru, then use that with the other AD commands
                $createUser = New-ADUser -Company:$user.Company -Department:$user.Department -DisplayName:$userName -GivenName:$user.'First Name' -Name:$userName -Path:"OU=Users,OU=Accounts,DC=gngrninja,DC=com" -SamAccountName:$samAccountName -Server:$adServer -Surname:"$($user.'Last Name')" -Type:"user" -UserPrincipalName:"$samAccountName@gngrninja.com" -PassThru
                
                #Set the password for the account. You would want to use a more secure method than this for the password, however :). Flag for reset on new logon
                Set-ADAccountPassword -Identity:$createUser -NewPassword:('p@$$w0rd' | ConvertTo-SecureString -AsplainText -Force) -Reset:$true -Server:$adServer
                
                #Set more password options
                Set-ADAccountControl  -Identity:$createUser -AccountNotDelegated:$false -AllowReversiblePasswordEncryption:$false -CannotChangePassword:$false -DoesNotRequirePreAuth:$false -PasswordNeverExpires:$false -Server:$adServer -UseDESKeyOnly:$false
                
                #Enable the account
                Enable-ADAccount      -Identity:$createUser -Server:$adServer
                
                #Perform action if account exists in AD
                if (Get-ADUser $createUser -Server $adServer) {

                    Write-Host "User [$($createUser.Name)] verified to exist in AD!"`n -ForegroundColor Green -BackgroundColor Black

                    $resultsObject = [PSCustomObject]@{

                        UserName         = $createUser.Name
                        AccountCreated   = $true 
                        ErrorMessage     = 'N/A'

                    }

                #Perform action if account is not found in AD
                } else {

                    Write-Host "User [$userName] not found in AD... something went wrong!"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = 'Account not found in AD!'

                    }

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null

            } 
            
            #Catch account already exists exception
            Catch [Microsoft.ActiveDirectory.Management.ADIdentityAlreadyExistsException] {

                Write-Host "User [$userName] already exists in AD!"`n -ForegroundColor Red -BackgroundColor DarkBlue
                
                $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = 'Account already exists in AD!'

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null

            }

            Catch {

                Write-Host "Error [$($_.Exception.Message)] occured when attempting to create [$userName]!"`n -ForegroundColor Red -BackgroundColor DarkBlue
                
                $resultsObject = [PSCustomObject]@{

                        UserName         = $userName
                        AccountCreated   = $false
                        ErrorMessage     = $_.Exception.Message

                }

                #Add results object to $resultsArray
                $resultsArray.Add($resultsObject) | Out-Null 
                               
            }

        #Else (basically if $skipUser is $true), perform this action
        } else {

            $userName = "$($user.'First Name') $($user.'Last Name')"
            Write-Host "Skipping $userName..."

            $resultsObject = [PSCustomObject]@{

                    UserName         = $userName
                    AccountCreated   = $false
                    ErrorMessage     = "Error validating data: [$csvValidation]!"

             }

             #Add results object to $resultsArray
             $resultsArray.Add($resultsObject) | Out-Null
        
        }

    }

}

#Return the $resultsArray array of objects
Return $resultsArray

Let's run this on a remote session to my lab's Domain Controller, and see what happens! I've saved the script as Invoke-NewUserCreation.ps1, in a directory that also contains a CSV of the users.

Since I return the object array of results, I will use the following command to run the script and store the results:

$createUsers = .\Invoke-NewUserCreation.ps1

There you can see some of what is happening, because I used the evil Write-Host command to display information usefully :)

However, the real power comes with the object we have now (which has detailed results). Let's take a look at $createUsers.

$createUsers | Format-Table -AutoSize

Here you can see all the information regarding what happened. We can even see where the first validation errors occurred for the users that were skipped.

Here is a screenshot of Active Directory Users and Computers on that Domain, showing the results:

Let's run the script again, to see what happens!

$createUsers = .\Invoke-NewUserCreation.ps1

Those errors were to be expected! Now let's see the object...

$createUsers | Format-Table -AutoSize

Now the object contains the error message stating the user account already exists.

Exporting Data

We've gone over importing, validating, and iterating through data. Now let's see what we need to do to export it!

Text Files

This is the simplest of the bunch. In this example I will use Get-Process to get a list of process names, and then export that to Processes.txt.

Get-Process | Select-Object -ExpandProperty Name | Out-File .\Processes.txt

Let's make sure the file exists, and that it has content!

Sure enough, it does! 

CSV Files

Exporting to CSV allows for better readability, and also works to import the data into other systems. The readability can also be used for reporting purposes.

Here is the $createUsers object we created earlier:

Let's export that to a CSV via the Export-Csv command. We will want to be sure to use the parameter -NoTypeInformation, as that will prevent PowerShell from also outputting the object's top information on top of the CSV (which we don't want!).  

$createUsers | Export-Csv -NoTypeInformation (".\ADresults{0:MMddyy_HHmm}.csv" -f (Get-Date))

Let's see if the file exists...

There it is! Let's take a look at the contents.

We have data, and it is ours to pretty up.

CliXML Files

I love CliXML files. I use them a lot, and they can really save you some time. We'll use the $createUsers object we used earlier, which contains the following information:

Now I will export that to an XML file via Export-Clixml.

$createUsers | Export-Clixml (".\ADresults{0:MMddyy_HHmm}.xml" -f (Get-Date))

Let's ensure the file exists:

For good measure, let's import the Clixml file and then compare the original object and imported object side-by-side:

$createUsersCopy = Import-Clixml .\ADresults062716_2041.xml

There you have it, they are identical! The CliXML commands provide an excellent method for storing PowerShell objects.

Homework

  • How could we have automatically made the CSV iteration example export the results to a CSV?

    • How about to CliXML?

  • What ways can you benefit from validation in your code?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to Top]

PowerShell: Getting Started - Creating Custom Objects

PowerShell: Getting Started - Creating Custom Objects

Getting Started - Creating Custom Objects

Welcome to my Getting Started with Windows PowerShell series!

What Are Custom Objects in PowerShell?

Everything we work with in PowerShell is an object in one way or another. A simple string is an object with the type of [System.String], using Get-Process returns objects with the type of [System.Diagnostics.Process], and using Get-ChildItem returns objects with the type [System.IO.FileInfo]

We can see that, here.

Custom objects are objects we create to collect data we want to collect. They can be comprised of objects from other datasets, arrays, and commands. Anything we can do in PowerShell can be fed into a custom object.

While we can create any type of object, from COM objects to objects based on .NET classes, we'll be focusing on the PSCustomObject. PSCustomObject is a .NET class [System.Management.Automation.PSCustomObject].

Why Create Custom Objects?

So that's nice... we can create custom objects, but why do it? Simple... output. In PowerShell it is best to keep your output from scripts and functions as objects you create or handle.  This allows for better readability and re-use. 

That way you can collect information, and iterate through it with built in commands such as Select-Object and Where-Object.

Since we can add anything to a custom object, from any other object/source in PowerShell, it can also be used as a method to organize information.

Let's take a look at a few example of creating custom objects.

Creating Custom Objects

Below I will be creating custom objects using various methods. It is important to understand that there isn't necessarily a best way to do anything in PowerShell. At the end of the day, the best way is the way that gets the job done for you.

However, I tend to gravitate towards methods that are easy for people looking at my scripts to interpret. This applies even when I'm looking at my own scripts! If I'm tired, and looking at some of my code, the last thing I need to do is try to figure out what I was doing based on code that isn't that easy to read.

Feel free to follow along! Open up the ISE and save a file to C:\PowerShell named part12.ps1. I will use the following code each time an example executes:

$adminPasswordStatus = $null
$thermalState        = $null
$osInfo              = Get-CimInstance Win32_OperatingSystem
$computerInfo        = Get-CimInstance Win32_ComputerSystem
$diskInfo            = Get-CimInstance Win32_LogicalDisk

Switch ($computerInfo.AdminPasswordStatus) {

    0 {$adminPasswordStatus = 'Disabled'}
     
    1 {$adminPasswordStatus = 'Enabled'}

    2 {$adminPasswordStatus = 'Not Implemented'} 

    3 {$adminPasswordStatus = 'Unknown'}

    Default {$adminPasswordStatus = 'Unable to determine'}

}

Switch ($computerInfo.ThermalState) {

    1 {$thermalState = 'Other'}

    2 {$thermalState = 'Unknown'}

    3 {$thermalState = 'Safe'}

    4 {$thermalState = 'Warning'} 

    5 {$thermalState = 'Critical'}

    6 {$thermalState = 'Non-recoverable'}

    Default {$thermalState = 'Unable to determine'}

}

Be sure to include the code above with any example you want to try in the ISE.

New-Object With Add-Member

With this method the first thing we'll do is create the object with the New-Object command, and store the object in a variable. Then we'll pipe the object to the Add-Member command. Add-Member will add the properties or methods we specify to the object we create.

We'll be using the MemberType of NoteProperty, and then give names to the properties (the names are whatever we want them to be). It's good to make them make sense! Then, finally, we'll define the Value as whatever expression we need to. In this case we'll use the some of the variables above. If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

$ourObject = New-Object -TypeName psobject 

$ourObject | Add-Member -MemberType NoteProperty -Name ComputerName -Value $computerInfo.Name
$ourObject | Add-Member -MemberType NoteProperty -Name OS -Value $osInfo.Caption
$ourObject | Add-Member -MemberType NoteProperty -Name 'OS Version' -Value $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
$ourObject | Add-Member -MemberType NoteProperty -Name Domain -Value $computerInfo.Domain
$ourObject | Add-Member -MemberType NoteProperty -Name Workgroup -Value $computerInfo.Workgroup
$ourObject | Add-Member -MemberType NoteProperty -Name DomainJoined -Value $computerInfo.Workgroup
$ourObject | Add-Member -MemberType NoteProperty -Name Disks -Value $diskInfo
$ourObject | Add-Member -MemberType NoteProperty -Name AdminPasswordStatus -Value $adminPasswordStatus
$ourObject | Add-Member -MemberType NoteProperty -Name ThermalState -Value $thermalState

Now let's run that, and then type $ourObject to see what our object contains.

That worked! We created an object, which contains information from all three sources. We can type $ourObject.Disks to see what is contained there.

Hashtable Using Add Method

Now let's take a look at using a hashtable to add our properties and values to the custom object. A hashtable is a collection of keys that have names with associated values.

For this example I will create the hashtable, and then call its Add method to add the pairs of data (separated by a comma). With this method we will actually create the object last, as we'll be adding the hashtable of properties and values to it when we create it.

If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

[hashtable]$objectProperty = @{}

$objectProperty.Add('ComputerName',$computerInfo.Name)
$objectProperty.Add('OS',$osInfo.Caption)
$objectProperty.Add('OS Version',$("$($osInfo.Version) Build $($osInfo.BuildNumber)"))
$objectProperty.Add('Domain',$computerInfo.Domain)
$objectProperty.Add('Workgroup',$computerInfo.Workgroup)
$objectProperty.Add('DomainJoined',$computerInfo.PartOfDomain)
$ObjectProperty.Add('Disks',$diskInfo)
$objectProperty.Add('AdminPasswordStatus',$adminPasswordStatus)
$objectProperty.Add('ThermalState',$thermalState)

$ourObject = New-Object -TypeName psobject -Property $objectProperty

Now let's see what $ourObject contains.

The code looks cleaner, but notice the order of the properties! It's not in the order we created the hashtable in. With Add-Member we were able to add each property, and it would keep the order we added them in.

That's not really a big deal, though! Since PowerShell has the nifty Select-Object command, we have an easy way to re-order the object.  

Let's try:

$ourObject | Select-Object ComputerName,OS,'OS Version',Domain,Workgroup,DomainJoined,AdminPasswordStatus,ThermalState,Disks

There we go, the order is as we want it now. Note that this will work to re-order any object you pass to it, and this can really come in handy.

Ordered Hashtable

!NOTE! Using this method is only supported in PowerShell version 3.0+

In this example I will declare the hashtable all at once, and then add the hashtable to the custom object we create. I will also use an ordered hashtable, so we can see how those work. That should allow us to keep the order of the properties correctly in the object. 

Here's the code:

$objectProperty = [ordered]@{

    ComputerName        = $computerInfo.Name
    OS                  = $osInfo.Caption
    'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
    Domain              = $computerInfo.Domain
    Workgroup           = $computerInfo.Workgroup
    DomainJoined        = $computerInfo.PartOfDomain
    Disks               = $diskInfo
    AdminPasswordStatus = $adminPasswordStatus
    ThermalState        = $thermalState

}

$ourObject = New-Object -TypeName psobject -Property $objectProperty

$ourObject

Let's run this [F5], and see what happens!

Looks good, and it is indeed in the right order!

Running the script from the console shows the object in the correct order as well.

PSCustomObject Type Adapter

!NOTE! Using this method is only supported in PowerShell version 3.0+

We can also create custom objects using the [PSCustomObject] type adapter.

If you're following along and didn't catch what we have in the variables, check the summary below.

$computerInfo contains information from Get-CimInstance Win32_ComputerSystem.
$osInfo contains information from Get-CimInstance Win32_OperatingSystem.
$diskInfo contains information from Get-CimInstance Win32_LogicalDisk.

Here's the code:

$ourObject = [PSCustomObject]@{

    ComputerName        = $computerInfo.Name
    OS                  = $osInfo.Caption
    'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
    Domain              = $computerInfo.Domain
    Workgroup           = $computerInfo.Workgroup
    DomainJoined        = $computerInfo.PartOfDomain
    Disks               = $diskInfo
    AdminPasswordStatus = $adminPasswordStatus
    ThermalState        = $thermalState

}

Let's take a look at $ourObject.

Nice! It also kept our order both in the ISE, and the console.

Here are the results from running part12.ps1 in the console:

Same result. Now let's look at how we can use this code to create an array of custom objects.

Creating a Custom Object Array

Why would we want to do such a thing?

Let's look at the following scenario:

You have a list of computers that you need to get information from. You want to capture any error messages that may come up. You also would like to capture what the computer name in the file was (which we will as ComputerInput), as well as the computer name returned when querying for information. We'll also want to gather the operating system and disk information.

To do this, we can use a ForEach loop, which calls a function, and utilize Try/Catch for error handling.

We'll also use a lot of what we learned today about creating custom objects. 

Setup

There is some setup to this, which is not included in the code. 

$credential contains a [System.Management.Automation.PSCredential] object, which stores the credentials to access my laptop for this example.

$computers contains an array of computer names to look up. For this example, I created a text file, and used Get-Content to import it into the $computers variable.

Looks like there are some potential script breakers in there!

Let's look at the code:

I've commented out the code to go over each and every step along the way. 

function Get-ComputerInformation { #Begin function Get-ComputerInformation
    [cmdletbinding()]
    param (
        [Parameter(
            Mandatory = $true
        )]
        [String]
        $computerName
    )

    Try { #Begin Try
        
        #Using Write-Host as Verbose is too chatty with the other commands
        Write-Host `n"Looking up information for: [$computerName]"`n

        #Null out variables used in the function
        #This isn't needed, but I like to declare variables up top
        $adminPasswordStatus = $null
        $thermalState        = $null
        $cimSession          = $null
        $computerObject      = $null
        $errorMessage        = $null

        #Except this guy, he gets a value that we'll use when errors are encountered
        $unableMsg           = 'Unable to determine'

        #Switch out the incoming $computerName
        #If it's localhost, we wont need to use our $credential variable when creating our CimSession
        #This creates the $cimSession variable, which encapsulates the object created with New-CimSession pointed at the target $computerName
        Switch ($computerName) {

            'localhost' {$cimSession = New-CimSession -ComputerName $computerName -ErrorAction Stop}

             Default    {$cimSession = New-CimSession -Credential $credential -ComputerName $computerName -ErrorAction Stop}

        }

        #Gather information using Get-CimInstance, pointed to our $cimSession variable
        #ErrorAction is set to Stop here, so we can catch any errors
        $osInfo       = Get-CimInstance Win32_OperatingSystem -CimSession $cimSession -ErrorAction Stop
        $computerInfo = Get-CimInstance Win32_ComputerSystem  -CimSession $cimSession -ErrorAction Stop
        $diskInfo     = Get-CimInstance Win32_LogicalDisk     -CimSession $cimSession -ErrorAction Stop

        #Use a switch to get the text value based on the number in $computerInfo.AdminPasswordStatus
        Switch ($computerInfo.AdminPasswordStatus) {

                  0 {$adminPasswordStatus = 'Disabled'}
     
                  1 {$adminPasswordStatus = 'Enabled'}

                  2 {$adminPasswordStatus = 'Not Implemented'}

                  3 {$adminPasswordStatus = 'Unknown'}

            Default {$adminPasswordStatus = 'Unable to determine'}

        }

        #Use a switch to get the text value based on the number in $computerInfo.ThermalState
        Switch ($computerInfo.ThermalState) {

                  1 {$thermalState = 'Other'}

                  2 {$thermalState = 'Unknown'}

                  3 {$thermalState = 'Safe'}

                  4 {$thermalState = 'Warning'} 
             
                  5 {$thermalState = 'Critical'}

                  6 {$thermalState = 'Non-recoverable'}

            Default {$thermalState = 'Unable to determine'}

        }

        #Create the object, cleanly!
        $computerObject = [PSCustomObject]@{

            ComputerInput       = $computerName
            ComputerName        = $computerInfo.Name
            OS                  = $osInfo.Caption
            'OS Version'        = $("$($osInfo.Version) Build $($osInfo.BuildNumber)")
            Domain              = $computerInfo.Domain
            Workgroup           = $computerInfo.Workgroup
            DomainJoined        = $computerInfo.PartOfDomain
            Disks               = $diskInfo
            AdminPasswordStatus = $adminPasswordStatus
            ThermalState        = $thermalState
            Error               = $false
            ErrorMessage        = $null

        }

        #Close the CimSession
        Remove-CimSession -CimSession $cimSession -ErrorAction Stop

        #Return the object created
        Return $computerObject
            
    } #End Try

    Catch { #Begin Catch

        #Capture the exception message in the $errorMessage variable
        $errorMessage = $_.Exception.Message    
        
        #Create our custom object with the error message     
        $computerObject = [PSCustomObject]@{

            ComputerInput       = $computerName
            ComputerName        = $unableMsg
            OS                  = $unableMsg
            'OS Version'        = $unableMsg
            Domain              = $unableMsg
            Workgroup           = $unableMsg
            DomainJoined        = $unableMsg
            Disks               = $unableMsg
            AdminPasswordStatus = $unableMsg
            ThermalState        = $unableMsg
            Error               = $true
            ErrorMessage        = $errorMessage
              
        }

        #Keeping Write-Host here for the colors! Hate all you want ;)
        Write-Host `n"Error encountered [$errorMessage]!"`n -ForegroundColor Red -BackgroundColor DarkBlue

        #Return the object created
        Return $computerObject 
            
        #Stop processing commands
        Break

    } #End Catch

} #End function Get-ComputerInformation

#Create the array we'll add the objects to
[System.Collections.ArrayList]$computerArray = @()

#Iterate through each computer in $computers with a ForEach loop
ForEach($computer in $computers) {

    #Use the Add method of the ArrayList to add the returned object from the Get-ComputerInformation function 
    #Piping this to Out-Null is important to suppress the result output from adding the object to the array
    $computerArray.Add((Get-ComputerInformation -computerName $computer)) | Out-Null

}

#Display the contents of the array.
$computerArray

Let's fire up the console, run the script, and take a look at the results!

It looks like the returned object is indeed an array of objects we've created in the function.

To do this I created an array before I called the ForEach loop, and then used the Add method to call the function with the current $computerName in the loop. This returns the object as a result, when each loop iteration runs.

We can also create a variable to store the results of the script via:

$computerArray = .\part12.ps1

You can see that using $computerArray[0] returned the first object in the array.

Want to export this to a CSV? Use this command:

$computerArray | Export-CSV .\results.csv -noTypeInformation

Let's open the file, and see if we were successful...

Now we can format it as we see fit!

!Note! Enumerating the disks and exporting them to the CSV is a bit more tricky as it is actually an object within an object.

This is but one of many ways to use objects! Again, there is no one right way. The right way is whatever works for you and your use case! 

Homework

  • How could you create a custom object that contains the computer name, along with network adapter, and IP information?

  • What interesting ways could you combine error handling with custom objects?

  • How can we properly export the disk information as well (from the custom object array example)?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Error Handling

PowerShell: Getting Started - Error Handling

Getting Started - Error Handling

Welcome to my Getting Started with Windows PowerShell series!

Why Handle Errors?

Error handling may take some extra time to create, however, it can really save you time in the long run. Let's say you have a script that performs the following tasks:

  • Takes information from a database / file and stores it in $userAccounts

  • Uses a loop to perform processing on each $user in $userAccounts

    • Update a user's AD information if the user is in the list

    • Disable any accounts that are not in the list

The script runs, and seems to be working great for a few weeks. Then, all of a sudden, the database server goes down, and $userAccounts is empty. The script runs, doesn't see any users, and what likely happens next is all user accounts are deleted. While this example is a little extreme, it is something that can happen. There are also a lot of in-between cases that can make error handling worth it. It's a good mindset to have when you're writing scripts as you can write to incorporate it off the bat.

Errors in PowerShell

Errors in PowerShell are stored in the automatic variable $error. You can see how many errors you've encountered in your session by using:

$error.count

This is essentially an array of errors, to access the first value you can use:

$error[0]

Let's take a look at $error[0] more closely, via:

$error[0] | Get-Member

We can view information on the command that raised the error via:

$error[0].InvocationInfo

It looks like the line property contains the full command we used when the error was encountered.

$error[0].InvocationInfo.Line

The exception that raised the error can be accessed via:

$error[0].Exception

We can also get more information about the exception via:

$error[0].Exception | Get-Member

Here you can see the TypeName: [System.UnauthorizedAccessException], and the various methods and properties of the exception. The exception TypeName is used later when we catch specific errors.

Looks like to get the exception's message in string format we'd use:

$error[0].Exception.Message

Now that we've dug into the automatic variable $error, let's move on to the different types of errors we can encounter.

Terminating Errors

Terminating errors in PowerShell mean that the script can no longer continue to run with the information it has encountered, or has been given. If you do not have a way to catch the error, it will likely display the nasty red error text you've likely seen before. Terminating errors halt anything running in the pipeline. This terminates output, aside from the error message of the terminating error. 

Here's an example of a terminating error that was caused by calling a command that does not exist:

Get-TerminatingError

Non-Terminating Errors

Non-terminating errors in PowerShell do not stop the pipeline from executing. These errors are handled internally by the Cmdlet, and are not able to be captured via error handling. There are ways, however, to make PowerShell treat non-terminating errors like terminating errors. That way we can use error handling to capture what's going on.

Here's an example of a non-terminating error (access denied to a subfolder), when attempting to list all folders and subfolders in "C:\Windows\appcompat\".

Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse

Force Non-Terminating Errors to Terminate

You can force non-terminating errors to become terminating errors in PowerShell. There are a couple ways you can do this. Why would you want to do this, you ask? Since non-terminating errors cannot be handled by us, we need to force them to terminate, and gain the functionality of handling the error ourselves. This is not always needed, but it is good to know that you can do it if you come across a use case for yourself.

$errorActionPreference

You can do it at a global way for the session via the $ErrorActionPreference variable. This is a special variable in PowerShell that allows you to control what happens when a non-terminating error is encountered.

Here are the values for $ErrorActionPreference.

  • Stop

    • Display error, and stop execution.

  • Inquire

    • Display error, and ask to continue.

  • Continue (Default)

    • This is the default setting. Display error, then continue execution.

  • Suspend

    • This one is for workflows. A workflow job is suspended to investigate what happened, then the workflow can be resumed.

  • SilentlyContinue

    • No error is displayed, execution is continued.

Let's see this in action.

Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse;Write-Host 'Test'

As you can see, with a non-terminating error, the next command in the sequence is executed. Therefore we see 'Test'. Let's set the $errorActionPreference automatic variable to Stop, and re-run the same command.

$ErrorActionPreference = 'Stop'
Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse;Write-Host 'Test'

It worked! This would also let us use error handling with this error in PowerShell now. As you can see, we do not see the output of the next command, as it never ran.

Use the Command's -ErrorAction Parameter

Cmdlet's and functions/scripts/modules that use [cmdletbinding()] enable utilization of the -ErrorAction common parameter. This parameter allows you to specify different actions to take when an error is encountered. 

  • Stop

    • Display error, and stop execution.

  • Inquire

    • Display error, and ask to continue.

  • Continue (Default)

    • This is the default setting. Display error, then continue execution.

  • Suspend

    • This one is for workflows. A workflow job is suspended to investigate what happened, then the workflow can be resumed.

  • SilentlyContinue

    • No error is displayed, execution is continued.

  • Ignore

    • The same as SilentlyContinue, but as SilentlyContinue still adds the message to the $error automatic variable, Ignore does not do this.

Let's set our $errorActionPreference to Continue, and then look at using the Cmdlet Get-ChildItem's -ErrorAction parameter.

We're setting $errorActionPreference back to Continue as earlier we set it to Stop for our other example. Continue is the default value.

$ErrorActionPreference = 'Continue'
Get-ChildItem -Path 'C:\Windows\appcompat' -Recurse -ErrorAction Stop;Write-Host 'Test'

Error Handling

There are a few different ways to to handle errors in PowerShell. The best way of course, is to never let them happen in the first place! To do that, it is good to have some data validation in place via an if statement, or whichever method suites your needs.

If a terminating error occurs, we can use Try/Catch/Finally blocks to perform different actions. If it is non-terminating, we can force it to become a terminating error, and then choose how to continue.

Validation

The simplest method of validation is the if statement. 

If you'd like to run these examples yourself, go ahead and fire up the PowerShell ISE. Save a file in C:\PowerShell as part11.ps1. 

An if statement is constructed as follows:

if (condition -eq $true) {

Do-Stuff

} else {

Do-Other-Stuff

}

The condition can be anything that resolves to a true value. That includes running a command that output (other than and error) occurs! Check this out:

if (Get-ChildItem Z:\ -ErrorAction SilentlyContinue) {

    Write-Host 'I can list the contents of Z:!'

} else {

    Write-Host 'I cannot list the contents of Z:!'

}

This does not return true, as expected, and thus we see the message from the code in the else portion of the if statement. We use the -ErrorAction common parameter with the value SilentlyContinue to suppress the error from being displayed to the end user of the script. No need in this case, as we're simply using the command for validation.

You can also use variables with the if statement to see if they are blank. 

$myVariable = $null

if ($myVariable) {

    Write-Host "We have information! Let's do stuff."

} else {

    Write-Host "`$myVariable is empty :("

}

The variable is empty, so the code in the else portion of the if statement is executed.

Try/Catch/Finally

The Try, Catch, and Finally blocks in PowerShell allow us to capture terminating errors. 

The Try block contains the code you'd like to execute, and catch any potential errors that happen.

The Catch block contains the code you'd like to execute after a terminating error has occurred. The current error will be accessible via the automatic variable $_.

The Finally block contains the code you'd like to run after the event has occurred. This is good for cleanup tasks. 

It is worth noting that finally block is not required.

Here is an example using Try/Catch/Finally:

Try {
    
    $command = 'Invoke-FakeCommand'

    Write-Host "Attempting to run: [Invoke-Expression -Command $command]"`n

    Invoke-Expression -Command $command

}

Catch {

    Write-Host $_.Exception.Message`n

}

Finally {

    Write-Host "Clean up: `$commmand = `$null"`n

    $commmand = $null

}

The code in the Try block executes and we see the output of Write-Host. We then see the error message that occurs, as our Catch block is writing out $_.Exception.Message. As we learned earlier, that is the string value of the exception that raised the error.

We then see the output from the Write-Host command in our Finally block. We use the finally block to free up the $command variable.

This version of Try/Catch will catch any terminating errors that are raised. To capture specific exceptions, you'll want to use the exception's TypeName.

Catch Specific Errors

Let's take a look at the following:

Try {

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}

The exception was caught, and the code in the Catch block was executed.

Now let's try this...

Try {
    
    Get-ChildItem -Path Z:\ -ErrorAction Stop
    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}

Ahh! An error that wasn't handled! Luckily we can add multiple catch blocks.

Let's add another Catch block.

Catch {

Write-Host $_.Exception.Message

}
Try {
    
    Get-ChildItem -Path Z:\ -ErrorAction Stop
    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {
    
    Write-Host "Command not found!"`n -ForegroundColor Red 
    Write-Host "Message: [$($_.Exception.Message)"] -ForegroundColor Red -BackgroundColor DarkBlue
    
}


Catch {

   Write-Host $_.Exception.Message

}

Now the exception from Get-ChildItem command is caught since the catch-all Catch block code is executed.

Getting Error Information

It can be handy to have a shortcut that shows you error information, which you can use to create specific Catch blocks. To do this, I created a function that I call in the Catch block which utilizes some validation itself! Here is the function:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Here is the full code I'll be running for this example:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Try {
   
    Get-ChildItem -Path Z:\ -ErrorAction Stop

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {

    Write-Host 'Command not found Catch block executed!' 

}

Catch {

   Get-ErrorInformation -incomingError $_

}

So now, if we wanted to catch this exception on it's own, we would need to add a catch block for [System.Management.Automation.DriveNotFoundException].

Catch [System.Management.Automation.CommandNotFoundException] {

Write-Host 'Command not found Catch block executed!' 

}

Let's add that to our code and run the following:

function Get-ErrorInformation {
    [cmdletbinding()]
    param($incomingError)

    if ($incomingError -and (($incomingError| Get-Member | Select-Object -ExpandProperty TypeName -Unique) -eq 'System.Management.Automation.ErrorRecord')) {

        Write-Host `n"Error information:"`n
        Write-Host `t"Exception type for catch: [$($IncomingError.Exception | Get-Member | Select-Object -ExpandProperty TypeName -Unique)]"`n 

        if ($incomingError.InvocationInfo.Line) {
        
            Write-Host `t"Command                 : [$($incomingError.InvocationInfo.Line.Trim())]"
        
        } else {

            Write-Host `t"Unable to get command information! Multiple catch blocks can do this :("`n

        }

        Write-Host `t"Exception               : [$($incomingError.Exception.Message)]"`n
        Write-Host `t"Target Object           : [$($incomingError.TargetObject)]"`n
    
    }

    Else {

        Write-Host "Please include a valid error record when using this function!" -ForegroundColor Red -BackgroundColor DarkBlue

    }

}

Try {
   
    Get-ChildItem -Path Z:\ -ErrorAction Stop

    Get-ThisWontWork

}

Catch [System.Management.Automation.CommandNotFoundException] {

    Write-Host 'Command not found Catch block executed!' 

}

Catch [System.Management.Automation.DriveNotFoundException] {

    Write-Host 'Get-ChildItem drive not found Catch block executed!'

}

Catch {

   Get-ErrorInformation -incomingError $_

}

There we go! Now all our errors are handled, minus the ones we don't know about yet.

Homework

  • Figure out why when multiple Catch blocks are used, it doesn't pass along the execution information (unable to get command/line).

    • Let me know why this is!

  • Where else could I have added error handling in any of these examples?

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to Top]

PowerShell: Getting Started - Jobs

PowerShell: Getting Started - Jobs

Getting Started - Jobs

PowerShell Background Jobs

Jobs in PowerShell allow you to run commands in the background. You use a command to start the job, and then various other commands to check in and receive information from it. Jobs can be started as 64-bit or 32-bit, regardless of what you launch your session as. You can also start jobs that run as a different user.  

Why Use PowerShell Jobs?

PowerShell jobs are handy for a lot of different uses. One could be running a script that contains multiple jobs that will perform different Active Directory tasks. You can run those in parallel, and use a While loop to check in on the jobs as they execute.

In this post you'll see how to start jobs, see running jobs, and get information as jobs complete. 

Creating Jobs

The command to start a job in PowerShell is Start-Job.

Let's use this command to start a job that will wait for one minute, and then execute the command Get-Process.

Start-Job -Name SleepProcess -ScriptBlock {Start-Sleep -Seconds 60; Get-Process}

The output of the job started is displayed after we execute the Start-Job command.

If you wanted to wait for this job to complete (which would make the console unusable until the job completes), you can use the Wait-Job command. With Wait-Job you'll need to specify the Job Name, Id, or simply pipe the Start-Job command to Wait-Job.

Start-Job -Name Wait -ScriptBlock {Start-Sleep -Seconds 60} | Wait-Job

And now we wait for the console to free up after 60 seconds have passed.

Monitoring Jobs

To keep an eye on the running jobs in PowerShell, we'll use the command Get-Job

Get-Job

Get-Job will show us all running jobs by default. We can also narrow it down to just our job via the Job Name or Job Id. Let's try getting this Job via the Job Id.

Get-Job -Id 1

If there were more Jobs running, we'd still only see this Job as we specified that we only want to see the Job with the Id value of 1.

Collecting Job Information

There are few different ways to collect information from running jobs in PowerShell. The official command to do it is Receive-Job. You'll need to specify the Job Name or Id when you use this command. Let's receive the information from this job, and specify the -Keep parameter so we can receive the information again later.

Receive-Job -Id 1 -Keep

You can see it displayed the output of Get-Process. Let's store the job in an object, and look at another way to receive the information.

This is a bit interesting. PowerShell stores most of the job information in a child job for each job you run. To access this information, you must access the ChildJobs property for the first child job, or 0. Let's take a look.

$ourOutput = Start-Job -Name GetOutput -ScriptBlock {Get-Process}

Here we started a job, and stored the object in the $ourOutput variable.

Now we can use that variable to take a look at the results.

$ourOutput

Let's see the available methods and properties for $ourOutput.

$ourOutput | Get-Member

Hey, there's the ChildJobs property! Let's take a look at it's properties and methods as well.

$ourOutput.ChildJobs[0] | Get-Member

Both $ourOutput and $ourOutput.ChildJobs[0] have the Output property. However, since our job is actually spawned as child job, this property will be empty on the initial object, and have a value only on the child object. 

$ourOutput.Output
$ourOutput.ChildJobs[0].Output

Error Handling

Error handling in jobs can be tricky, as sometimes a job state will show as completed, but the output of the job is actually a failed command. There are other times where a job will state that is actually has failed. In each case, there are two main spots to check for error information when a job fails.

Let's take a look at the two examples. I can replicate different states with the New-Item command and toggling the -ErrorAction parameter to Stop.

Job State Failed

$failJob = Start-Job -Name FailJob -ScriptBlock {New-Item -Path 'Z:\' -Name 'test' -ItemType Directory -ErrorAction Stop}

Let's look at the $failJob object to make sure it has indeed failed...

$failJob

Alright... now here is the weird part. Normally you'd look at the ChildJob's Error property to get the error message. That... or Receive-Job would show you an error. I have tried all of those approaches. It looks like Receive-Job will display the error, but you're unable to store it in a variable or parse the message in any way. Not helpful! 

$jobError = Receive-Job $failJob
$failJob.ChildJobs[0].Error
$jobError

So how do we see what happened? We need to look at the property $failJob.ChildJobs[0].JobStateInfo.Reason.Message to get the message of what happened (as a string). 

$failJob.ChildJobs[0].JobStateInfo.Reason.Message
$jobError = $failJob.ChildJobs[0].JobStateInfo.Reason.Message
$jobError

There we go, got the error! 

Job State Completed

There are times when the job will complete, and the error message will indeed be in the ChildJob's Error property. Let's take the -ErrorAction Stop off the New-Item command and start the job again.

$failJob = Start-Job -Name FailJob -ScriptBlock {New-Item -Path 'Z:\' -Name 'test' -ItemType Directory}

Now let's verify it shows as completed.

$failJob

This job has essentially the same error message as the other error example above. The difference is the error is stored in the ChildJob property of Error. This is one of the most confusing aspects of PowerShell jobs.  Receive-Job will display the error, but also like above not let you store it.  Not good for automation!

Here's how we get the error information this time.

$failJob.ChildJobs[0].Error
$jobError = $failJob.ChildJobs[0].Error
$jobError

Job Cleanup

Now that we've gone over how to create jobs, receive their information, and perform some error handling... let's move on to cleanup. 

The Remove-Job command will remove jobs from PowerShell. You can see this from start to finish with this example. 

$ourJob = Start-Job -Name CleanUp -ScriptBlock {Get-Process}
$ourJob
Get-Job

You can see the CleanUp job name amidst all of the other jobs I still have from this post. 

To remove this job only, you can use Remove-Job with the Job Name or Id. We can also pipe the variable we stored our job object in ($ourJob) to Remove-Job.

$ourJob | Remove-Job
Get-Job

And it's gone! What if we wanted to remove all of these jobs? You can actually pipe Get-Job to Remove-Job.

Get-Job | Remove-Job
Get-Job

As you can see, all of the jobs are now cleaned up.

Multiple Job Example

The glory of PowerShell Jobs is being able to run multiple commands at the same time. This example code will start up multiple jobs, and then use a While loop to monitor them. As they complete, the output and/or error messages will be output to a text file with the job name and date stamp as the file name. The output folder is C:\PowerShell\part10\output. 

Copy and paste the code in the ISE, and ensure you can write to C:\PowerShell\Part10. If you can't, you may see some nasty error messages, as I have not added any error handling to this example. Save the script as C:\PowerShell\Part10\part10.ps1.

Code

#Set the jobs variable to $true so the while loop processes at least once
$jobs         = $true
#Set the output folder path
$outputFolder = 'C:\PowerShell\part10\output'

#Create some jobs to monitor. One for each error handling example, and then one success that takes a minute to complete.
Start-Job -Name SleepProcess -ScriptBlock {Start-Sleep -Seconds 60; Get-Process}
Start-Job -Name FailJob -ScriptBlock {New-Item -Path 'Z:\' -Name 'test' -ItemType Directory -ErrorAction Stop} 
Start-Job -Name FailCompletedJob -ScriptBlock {New-Item -Path 'Z:\' -Name 'test' -ItemType Directory} 

#If the folder doesn't exist, create it
If (!(Test-Path $outputFolder)) {

    New-Item -Path $outputFolder -ItemType Directory

}

#While $jobs = $true...
While ($jobs) { #Begin $jobs While Loop

    #Store the jobs in $ourJobs
    $ourJobs = Get-Job

    Write-Host "Checking for jobs..."

    #Use a ForEach loop to iterate through the jobs
    foreach ($jobObject in $ourJobs) { #Begin $ourJobs ForEach loop
        
        #Null out variables used in this loop cycle
        $jobResults   = $null
        $errorMessage = $null
        $jobFile      = $null
        $jobCommand   = $null

        #Store the command used in the job to display later
        $jobCommand   = $jobObject.Command

        #Use the Switch statement to take different actions based on the job's state value
        Switch ($jobObject.State) { #Begin Job State Switch

            #If the job state is running, display the job info
            {$_ -eq 'Running'} {

                Write-Host "Job: [$($jobObject.Name)] is still running..."`n
                Write-Host "Command: $jobCommand"`n

            }

            #If the job is completed, create the job file, say it's been completed, and then perform an error check
            #Then display different information if an error is found, versus successful completion
            #Use a here-string to create the file contents, then add the contents to the file
            #Finally use Remove-Job to remove the job
            {$_ -eq 'Completed'} {
                
                #Create file
                $jobFile = New-Item -Path $outputFolder -Name ("$($jobObject.Name)_{0:MMddyy_HHmm}.txt" -f (Get-Date)) -ItemType File

                Write-Host "Job [$($jobObject.Name)] has completed!"

                #Begin completed but with error checking...
                if ($jobObject.ChildJobs[0].Error) {

                    #Store error message in $errorMessage
                    $errorMessage = $jobObject.ChildJobs[0].Error | Out-String

                    Write-Host "Job completed with an error!"`n
                    Write-Host "$errorMessage"`n -ForegroundColor Red -BackgroundColor DarkBlue

                    #Here-string that contains file contents
                    $fileContents = @"
Job Name: $($jobObject.Name)

Job State: $($jobObject.State)

Command:

$jobCommand

Error:

$errorMessage
"@

                    #Add the content to the file
                    Add-Content -Path $jobFile -Value $fileContents

                } else {
                    
                    #Get job result and store in $jobResults
                    $jobResults = Receive-Job $jobObject.Name

                    Write-Host "Job completed without errors!"`n
                    Write-Host ($jobResults | Out-String)`n

                    #Here-string that contains file contents
                    $fileContents = @"
Job Name: $($jobObject.Name)

Job State: $($jobObject.State)

Command: 

$jobCommand

Output:

$($jobResults | Out-String)
"@

                    #Add content to file
                    Add-Content -Path $jobFile -Value $fileContents

                }

                #Remove the job
                Remove-Job $jobObject.Name
             
            }

            #If the job state is failed, state that it is failed and then create the file
            #Add the error message to the file contents via a here-string
            #Then use Remove-Job to remove the job
            {$_ -eq 'Failed'} {

                #Create the file
                $jobFile    = New-Item -Path $outputFolder -Name ("$($jobObject.Name)_{0:MMddyy_HHmm}.txt" -f (Get-Date)) -ItemType File
                #Store the failure reason in $failReason
                $failReason = $jobObject.ChildJobs[0].JobStateInfo.Reason.Message 

                Write-Host "Job: [$($jobObject.Name)] has failed!"`n
                Write-Host "$failReason"`n -ForegroundColor Red -BackgroundColor DarkBlue
                
                #Here-string that contains file contents
                $fileContents = @"
Job Name: $($jobObject.Name)

Job State: $($jobObject.State)

Command: 

$jobCommand

Error:

$failReason
"@
                #Add content to file
                Add-Content -Path $jobFile -Value $fileContents

                #Remove the job
                Remove-Job $jobObject.Name
            }


        } #End Job State Switch
     
    } #End $ourJobs ForEach loop

    #Clear the $ourJobs variable
    $ourJobs = $null

    #Get the new list of jobs as it may have changed since we did some cleanup for failed/completed jobs
    $ourJobs = Get-Job 

    #If jobs exists, keep the loop running by setting $jobs to $true, else set it to $false
    if ($ourJobs) {$jobs = $true} else {$jobs = $false}

    #Wait 10 seconds to check for jobs again
    Start-Sleep -Seconds 10

} #End $jobs While Loop

Here's the code in action...

So there's the output to the console. Let's check out the C:\PowerShell\Part10\Output folder.

That looks good. Let's take a peek at each file's contents.

And there you have it! You can do whatever you'd like with the job information in the Switch statement.

Homework

  • Jobs are capable of being run in different ways, including remotely. There are also a handful of WMI command that can be started as jobs. To see more ways jobs can be used, check out these resources.
  • Add some error handling to the script example in this post. 
    • Post ideas in the comments!

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to Top]

PowerShell: Getting Started - All About Strings!

PowerShell: Getting Started - All About Strings!

Getting Started - All About Strings!

Welcome to part 9 of my series in getting started with Windows PowerShell!

In case you missed the earlier posts, you can check them out here:

We will be exploring:

What's a string?

Back in Part 1, I mentioned that everything in PowerShell is an object. That's true! Today I'll be going over one object type, and that's System.String. A string in PowerShell is simply an object with that type.

Let's start by using the command Get-Location, and using the variable $ourPath to hold that object. We'll then get the value of the variable, and see what object type is returned.

$ourPath = Get-Location

Let's see what $ourPath returns.

Now let's pipe $ourPath to Get-Member and see what object type, methods, and properties are returned.

$ourPath | Get-Member

The object TypeName is System.Management.Automation.PathInfo. To get the string returned, we need to drill down into the Path property.

$ourPath.Path should return a TypeName of System.String.

There it is! Now that we know that, we can move on to string manipulation. 

Manipulating Strings

Let's re-create the $ourPath variable and set it to become just the Path property of Get-Location. If you would like to follow along, open PowerShell and navigate to C:\PowerShell. Create a directory there named part9, and then navigate inside of that folder. 

You can use the following sequence of commands to create the folder if it doesn't exist, and then navigate to it.

$path = 'C:\PowerShell\part9'

New-Item -Path $path.SubString(0,$path.LastIndexOf('\')+1) -ItemType Directory -Name $path.SubString($path.LastIndexOf('\')+1)

Set-Location $path

Now that we're inside of the part9 folder, we can use the following command to store the path in the $ourPath variable.

$ourPath = (Get-Location).Path

Note: the $ourPath variable should match the $path variable. 

Now that we have our string stored in $ourPath, we can start manipulating it. I will be using the SubString and LastIndexOf methods. 

What if you wanted to just get the value of the child folder you're currently in? In this case that folder name would be part9.

We can use both SubString and LastIndexOf to perform this task.

SubString

The SubString method accepts two overload arguments. They are separated by a comma, and are numerical. Value 1 (left) is the value of where you'd like to start the SubString, and value 2 is how many characters you'd like to move to the right of where you started. For example, if we run the following command:

$ourPath.SubString(0,8)

You can see that the values of (0,8) with the string 'C:\PowerShell\part9' returns C:\Power. That is because we start at the first character (0), and end after the value (8) is reached.  Notice that from left to right, the letter 'r' is the eighth letter in the sequence.

Now that we know this, if we wanted to just return 'part9', we can use:

$ourPath.SubString(14,5)

There we go! We start out at (14), or 'p', and then end at (5), which puts us from p -> 9, returning part9.

For this example that's great, but what if we needed to make something auto-magically get the value of the child folder?

LastIndexOf

LastIndexOf accepts an overload argument that allows you to specify the character you'd like to look for. It will return the numerical value of its location in the string, the last time it is seen. That is to say, if we use the value '\' in a path, it will return the value of the where the very last occurrence of '\' is.

Let's take a look at:

$ourPath.LastIndexOf('\')

Awesome! This is handy as it will allow us to automatically get the name of the child folder, if need be. We would just need to start one value out from the LastIndexOf  '\'.

Now we can combine SubString, and LastIndexOf. We'll leave the second value of SubString blank, which will essentially tell SubString where to start, and then to give us everything after that. We will add +1 after the LastIndexOf method is called to add one to the value returned.

$ourPath.Substring($ourPath.LastIndexOf('\')+1)

Let's take a look at another example of this in use. I will get a list of folders on my F:\ drive, and then use a ForEach-Object loop to iterate through and only display the folder names. You can do this with Get-ChildItem as well, but that wouldn't help this example :)

$folders = Get-ChildItem F:\ -Directory
$folders | ForEach-Object { $_.FullName }

Now let's make that loop happen and only display the folder names.

$folders | ForEach-Object { $_.FullName.SubString($_.FullName.LastIndexOf('\')+1)}

While this has some practical uses, if you're using Get-ChildItem, it actually returns the Name property for you. We can use the following code to get the same result:

Get-ChildItem F:\ -Directory | Select-Object -ExpandProperty Name

Expanded Strings

An expanded string in PowerShell allows you to expand the values of variables inside of the string before it is executed and displayed. Expanded strings are surrounded in "double quotes". Let's take a look at this using the following code:

$test = 'This is a test'
Write-Host "Test: [$test]"

You can see that the Write-Host displayed the value of the $test variable inside of the expanded string.

Let's take a look at another example, this time using Get-Process, looking for Chrome.

$process = Get-Process Chrome

Write-Host "Process: $process"

Well that doesn't look nice! That's because there are both a lot of processes, as well as different properties of each process returned. The default isn't very pretty. If you want to expand an object's property inside of an expanded string, you'll need to encapsulate it inside of a sub-expression. Let's create a ForEach-Object loop, and then display the property name of each process in it.

$process | ForEach-Object {

    Write-Host "Process: $($_.Name)"

}

That looks much better!

What if we wanted to display the actual variable name in the output, and not its value? To do this, we'll need to use the escape character, or `

Let's take a look at that with the $test variable.

Write-Host "The value of `$test is: [$test]"

Literal Strings

Literal strings in PowerShell do not allow variable expansion, and will simply display what is inside of the quotes as-is. Literal strings are surrounded in 'single quotes'. Let's take a look at an example:

$test        = 'This is a test'
Write-Host 'This is a literal string: [$test]'

The -f Operator

The -f operator in PowerShell allows you to format strings in various predefined ways. You can apply formatting to characters and numbers. Here is one example of using the operator:

[array]$formatArray = @('you','values','them.')
$user               = (Get-ChildItem Env:\USERNAME).Value
$date               = Get-Date
"Your user name is {0}, and the time is [{1:HH}:{1:mm}:{1:ss}]" -f $user,$date

For more information on how to use the format operator, check out this post:

Converting To and From Strings

In PowerShell, you can use the ToString method to convert an object to a string. You can also declare a variable, cast as [string]. Either way works. Let's look at the following code: 

[int]$number = 10

$number | Get-Member

As expected, the object type for $number is Int32. Let's try converting that to a string via ToString.

$numberString = $number.ToString()

$numberString | Get-Member

Aha! Now the type is a String!

Now, finally, let's look at casting the variable with explicit types.

[string]$number | Get-Member

We can also make it an int again, just as easily.

[int]$number | Get-Member

Building a ScriptBlock

We can use expanded strings to build a scriptblock, which we can then use for other PowerShell commands. Let's start by declaring the variable $findProcess with the process we'd like to find. Then we can create the expression via the [scriptblock] member, and the Create method. We'll then set that overload to the command we'd like to run.

Then we'll use the command Start-Job to start a PowerShell job, and run that command. 

Here's the code:

$findProcess = 'chrome'

$expression = [scriptblock]::Create("Get-Process $findProcess | Select-Object ProcessName, CPU, Path | Format-List")

Start-Job -Name "$findProcess`Process" -ScriptBlock $expression

We used an expanded string to create the code being run in the script block, as well as to define the name of the job. We'll now use the following code to receive and clean up the job:

Receive-Job "$findProcess`Process"

Get-Job "$findProcess`Process" | Remove-Job

As you can see the job is received, and we have the output of the command we created and executed via PowerShell jobs. 

We used the expanded string to ensure we received and cleaned up the right job, and did so dynamically.

PowerShell jobs will be covered in a later portion of this series!

Homework

  • Play around with all the different methods and properties of strings in PowerShell!
  • Get a head start learning about PowerShell Jobs.
    • Get-Help about_jobs

[Back to Top]

PowerShell: Getting Started - Accepting Pipeline Input

PowerShell: Getting Started - Accepting Pipeline Input

Getting Started - Accepting Pipeline Input

Pipeline Review

The pipeline in PowerShell is what commands use when separated with the pipeline operator '|'. It allows commands to pass the output values to the next command in the sequence. You can pipe as many commands together as necessary to complete your task. 

Pipeline structure:

Command1 (output) -> | Command2 (output) -> | Command3

Using the pipeline

Let's start with Get-Process.

Get-Process

This command will give you a list of all running processes, and some information for each process.

What if we just cared about the first 10 processes, sorted by CPU usage?

Let's try:

Get-Process | Sort-Object CPU -Descending | Select-Object -First 10

The above command takes Get-Process, then pipes it to Sort-Object (which we have sorting by CPU, descending), and then pipes that to Select-Object so we can specify that we want to see the First 10 results only. 

We can use the same command, but specify -Last 10 to see the last 10 results.

Get-Process | Sort-Object CPU -Descending | Select-Object -Last 10

Pretty handy! What if we wanted to see this in a different way? You can take the above command, and then pipe it to Out-GridView.

Get-Process | Sort-Object CPU -Descending | Select-Object -Last 10 | Out-GridView

Accepting pipeline input

So now that we know how to work with the pipeline in PowerShell, how do we accept pipeline input in our code?

Accepting pipeline input is possible via parameter attributes available when using [cmdletbinding()].

There are two different parameter attributes we can use that will accept input from the pipeline. 

ValueFromPipeline

Let's take a look at ValueFromPipeline. This parameter attribute will take all values passed to it from the pipeline. To get started with this example, open the ISE and paste in the following code:

function Write-PipeLineInfoValue {
[cmdletbinding()]
param(
    [parameter(
        Mandatory         = $true,
        ValueFromPipeline = $true)]
    $pipelineInput
)

    Begin {

        Write-Host `n"The begin {} block runs once at the start, and is good for setting up variables."
        Write-Host "-------------------------------------------------------------------------------"

    }

    Process {

        ForEach ($input in $pipelineInput) {

            Write-Host "Process [$($input.Name)] information"

            if ($input.Path) {
        
                Write-Host "Path: $($input.Path)"`n
        
            } else {

                Write-Host "No path found!"`n -ForegroundColor Red

            }

        }

    }

    End {

        Write-Host "-------------------------------------------------------------------------------"
        Write-Host "The end {} block runs once at the end, and is good for cleanup tasks."`n

    }

}

Get-Process | Select-Object -First 10 | Write-PipeLineInfoValue

Go ahead and save it to C:\PowerShell\part8.ps1, and run the code.

Here are the results:

When accepting pipeline input, the input is handled in the Process {} block within the function. You can also use the Begin {} and End {} blocks as well. The Process block is mandatory, however Begin and End blocks are not.

The code in the begin block runs once when the function is invoked. It is where you can setup variables at the start of the code execution.

Begin {

        Write-Host `n"The begin {} block runs once at the start, and is good for setting up variables."
        Write-Host "-------------------------------------------------------------------------------"

    }

The code in the process block will process the pipeline input. It is cleanest, in my opinion, to handle each element of the pipeline (as it will typically come in as an array), in a ForEach loop. That way you can iterate through each item, and take action on it. 

Process {

        ForEach ($input in $pipelineInput) {

            Write-Host "Process [$($input.Name)] information"

            if ($input.Path) {
        
                Write-Host "Path: $($input.Path)"`n
        
            } else {

                Write-Host "No path found!"`n -ForegroundColor Red

            }

        }

    }

The code in the end block will run after all the elements in the pipeline are processed. This is a good place to cleanup variables, or anything else you need to do once, after the pipeline input is handled.

End {

        Write-Host "-------------------------------------------------------------------------------"
        Write-Host "The end {} block runs once at the end, and is good for cleanup tasks."`n

    }

ValueFromPipelineByPropertyName

This parameter attribute is a bit different than the one above. It will only accept the input from the pipeline if the incoming property name matches the parameter name. In this example, I will use the parameter name of $name to match any incoming property values of the property 'name'.

Go ahead and paste this code under the code we used earlier in part8.ps1

function Write-PipeLineInfoPropertyName {
[cmdletbinding()]
param(
    [parameter(
        Mandatory                       = $true,
        ValueFromPipelineByPropertyName = $true)]
    [string[]]
    $Name
)

    Begin {

        Write-Host `n"The begin {} block runs once at the start, and is good for setting up variables."
        Write-Host "-------------------------------------------------------------------------------"

    }

    Process {

        ForEach ($input in $name) {

            Write-Host "Value of input's Name property: [$($input)]"

        }

    } 

    End {

        Write-Host "-------------------------------------------------------------------------------"
        Write-Host "The end {} block runs once at the end, and is good for cleanup tasks."`n

    }

}

Get-Process | Select-Object -First 10 | Write-PipeLineInfoPropertyName

Let's run this code, and take a look at the results:

In this example we did not have to expand upon the incoming object's values, as it only gave us the property name values to begin with. That is, we didn't have to use $input.Name, we just had to use $input.

We can also run the above code with Get-Service in place of Get-Process, as it also returns a property name of 'name'.

Get-Service | Select-Object -First 10 | Write-PipeLineInfoPropertyName

Uses

There are many uses for accepting pipeline input. Let's say you need to get a list of specific computers that you need to perform actions on. You could simply pipe that to a function you created that would perform actions on each one.

If you create a logging function, you can also take bulk messages or commands and the pipe them to a function that creates or logs to a log file.

As with most things in PowerShell, there can be many ways to complete the same task. Sometimes piping commands together is cleaner than other methods, and sometimes it isn't. In the end it's up for you to decide.

Homework

  • Learn more about the pipeline!
    • Get-Help about_Pipelines
  • Try passing other commands to the functions we created today.

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Back to top]

PowerShell: Getting Started - Help!

PowerShell: Getting Started - Help!

Getting Started - Help!

Adding help

Adding help to your code is a great way to help people learn how to use it! It will also be useful for yourself if you have scripts you may use down the road, but do not need to use them all the time. It is easy to add, and maintain. The method of help I will be demonstrating is accessible via the Get-Help command. 

Comment block creation

To create comment based help in PowerShell, you'll need the following basic layout:

<#   
.SYNOPSIS   
   A brief description of what your code does.
.DESCRIPTION 
   A full description of what your code does.
.NOTES   
    Name: Script/function/module name
    Author: Your Name
    DateCreated: The current date
#>

It is important to note that the comment section begins with '<#', and ends with '#>'.

This block should go above all of the code you are going to use, and is the only thing in PowerShell allowed above the [cmdletbinding()] statement.

For this basic example we will display the synopsisdescription, and notes keywords. There are more keywords, and I will be going over those later in the accessing keywords section. All of the keyword identifiers must start with a period '.' for PowerShell to recognize them.

Here are some examples of the help block in action.

Script

Get-Help .\part7example.ps1

Function

To see this one we will need to import the module containing the function.

Import-Module .\part7examplemodule.psm1

Now we can use Get-Help, along with the function name, to display the help block's contents.

Get-Help Get-HelpExample

Accessing various keywords

You can access various keywords using different parameters with Get-Help. For this example I will be adding help to our function Write-Pretty from Part 6.

We'll be using the following comment based help block:

<#   
.SYNOPSIS   
   This function will output text in different ways depending on the arguments received.
.DESCRIPTION 
   Write-Pretty will output text in the following ways:

    ------
   |Random|
    ------
    Random will add some spacing and split your text into an array using a space. It will take each part of the array and apply a random color to it.

    [Random] will be prepended to the text.

    -----
   |Error|
    -----
    Error will output the text as red.

    [Error] will be prepended to the text.

    -------
   |Warning|
    -------
    Warning will output the text as yellow.

    [Warning] will be prepended to the text.

    ----
   |Info|
    ----
    Info will output the text as as white, as is the default option if you do not specify any parameters. 

    [Info] will be prepended to the text.
   
.PARAMETER prettyText

    Alias: Text
    Argument: This parameter is the text you'd like to affect the look of.

    This parameter accepts value from the pipeline.
   
.PARAMETER textType
    Alias: Type
    Argument: This parameter affects the type of text you'd like to display.

    Valid types:
    Random
    Error
    Warning
    Info

.NOTES   
    Name: part7.psm1
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/8/16

.LINK  
    http://www.gngrninja.com/script-ninja/2016/5/8/powershell-getting-started-part-7-help  
       
.EXAMPLE   
    Write-Pretty -Text 'This is some pretty text.' -Type Random
    ---------------------------------------------------------------

    [Random] This is some pretty text

.EXAMPLE   
    Write-Pretty -Text 'This is some pretty text, and it is now an error.' -Type Error
    ---------------------------------------------------------------

    [Error] This is some pretty text, and it is now an error

.EXAMPLE   
    Write-Pretty -Text 'Warning, this will output warning text!' -Type Warning
    ---------------------------------------------------------------

    [Warning] Warning, this will output warning text!

.EXAMPLE   
    Write-Pretty -Text 'This will output info text.' -Type Info
    ---------------------------------------------------------------

    [Info] This will output info text.

.EXAMPLE  
    Write-Pretty "I wonder what happens if we don't specify an option..."
    ---------------------------------------------------------------

    [Info] I wonder what happens if we don't specify an option...

.EXAMPLE
    Get-Process | Select-Object -ExpandProperty Name -First 5 | Sort-Object Name | Write-Pretty
    ---------------------------------------------------------------

    [Info] Battlenet
    [Info] Battlenet Helper
    [Info] AGSService
    [Info] AdobeUpdateService
    [Info] Agent
#>

I've added some more keywords so we can use Get-Help in various ways with the function Write-Pretty. The link keyword allows you to add a link for use with the -online parameter. The parameter keyword allows you to display help information for each parameter via the -parameter <name> parameter and argument. I also added some examples of the code being used, and expected output via the examples keyword. This keyword can be access via the -examples parameter with Get-Help.

Let's take a look at the full code for this post. If you'd like to try it out for yourself, open the ISE and save the code as C:\PowerShell\part7.psm1.

#Begin function Write-Pretty
function Write-Pretty {
<#   
.SYNOPSIS   
   This function will output text in different ways depending on the arguments received.
.DESCRIPTION 
   Write-Pretty will output text in the following ways:

    ------
   |Random|
    ------
    Random will add some spacing and split your text into an array using a space. It will take each part of the array and apply a random color to it.

    [Random] will be prepended to the text.

    -----
   |Error|
    -----
    Error will output the text as red.

    [Error] will be prepended to the text.

    -------
   |Warning|
    -------
    Warning will output the text as yellow.

    [Warning] will be prepended to the text.

    ----
   |Info|
    ----
    Info will output the text as as white, as is the default option if you do not specify any parameters. 

    [Info] will be prepended to the text.
   
.PARAMETER prettyText

    Alias: Text
    Argument: This parameter is the text you'd like to affect the look of.

    This parameter accepts value from the pipeline.
   
.PARAMETER textType
    Alias: Type
    Argument: This parameter affects the type of text you'd like to display.

    Valid types:
    Random
    Error
    Warning
    Info

.NOTES   
    Name: part7.psm1
    Author: Ginger Ninja (Mike Roberts)
    DateCreated: 5/8/16

.LINK  
    http://www.gngrninja.com/script-ninja/2016/5/8/powershell-getting-started-part-7-help  
       
.EXAMPLE   
    Write-Pretty -Text 'This is some pretty text.' -Type Random
    ---------------------------------------------------------------

    [Random] This is some pretty text

.EXAMPLE   
    Write-Pretty -Text 'This is some pretty text, and it is now an error.' -Type Error
    ---------------------------------------------------------------

    [Error] This is some pretty text, and it is now an error

.EXAMPLE   
    Write-Pretty -Text 'Warning, this will output warning text!' -Type Warning
    ---------------------------------------------------------------

    [Warning] Warning, this will output warning text!

.EXAMPLE   
    Write-Pretty -Text 'This will output info text.' -Type Info
    ---------------------------------------------------------------

    [Info] This will output info text.

.EXAMPLE  
    Write-Pretty "I wonder what happens if we don't specify an option..."
    ---------------------------------------------------------------

    [Info] I wonder what happens if we don't specify an option...

.EXAMPLE
    Get-Process | Select-Object -ExpandProperty Name -First 5 | Sort-Object Name | Write-Pretty
    ---------------------------------------------------------------

    [Info] Battlenet
    [Info] Battlenet Helper
    [Info] AGSService
    [Info] AdobeUpdateService
    [Info] Agent
#>  
    [cmdletbinding()]
    param(
    [Parameter(
                Mandatory         = $True,
                ValueFromPipeline = $True
               )]
    [Alias('Text')]
    $prettyText,
    [Parameter(Mandatory=$false)]
    [Alias('Type')]
    $textType
    )

    Begin {
    
        #Create a space before the text is displayed.
        Write-Host `n 

    }

    Process { #Begin process for Write-Pretty function

        ForEach ($textItem in $prettyText) { #Begin ForEach loop to handle prettyText input (normal and via pipeline)

            Switch ($textType) { #Begin switch for textType argument

                {$_ -eq 'Random'} {

                    Write-Host -NoNewline "[" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15) 
                    Write-Host -NoNewline "R" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)
                    Write-Host -NoNewline "andom" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)
                    Write-Host -NoNewline "]" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)

                    #Split the text into an array, split by spaces. (Also turns it into a string before the split).
                    #We needed to turn it into a string in case the type wasn't string when it was received. Or else the .Split() method wouldn't work
                    $writeText  = $textItem.ToString().Split(' ')

                    #Change the text color for each element in the array.
                    ForEach ($text in $writeText) {

                        Write-Host -NoNewLine " $text" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)

                    }

                    Write-Host `n
            
                }

                {$_ -eq 'Error'} {

                    Write-Host -NoNewline "[" -ForegroundColor White 
                    Write-Host -NoNewline "Error" -ForegroundColor Red -BackgroundColor DarkBlue
                    Write-Host -NoNewline "]" -ForegroundColor White 
                    Write-Host " $textItem" -ForegroundColor Red 

                }


                {$_ -eq 'Warning'} {

                    Write-Host -NoNewline "[" -ForegroundColor White
                    Write-Host -NoNewline "Warning" -ForegroundColor Yellow -BackgroundColor Blue
                    Write-Host -NoNewline "]" -ForegroundColor White
                    Write-Host " $textItem" -ForegroundColor Yellow


                }

                {$_ -eq 'Info' -or $_ -eq $null} {

                    Write-Host -NoNewline "[" -ForegroundColor White
                    Write-Host -NoNewline "Info" -ForegroundColor Green -BackgroundColor Black
                    Write-Host -NoNewline "]" -ForegroundColor White
                    Write-Host " $textItem" -ForegroundColor White

                }

                #The default option will simply write the text with no changes. This is if you do not specify a valid option for textType.
                Default { 
        
                    Write-Host $textItem
        
                }

            } #End switch for textType argument

        } #End ForEach loop to handle prettyText input (normal and via pipeline)

    } #End process for Write-Pretty function

    End {
    
        Write-Host `n

    }

} #End function Write-Pretty

#Display this message when the module is imported.
Write-Pretty -Text 'Part7 module loaded!' -textType Random

Now we'll need to import the module...

Import-Module .\part7.psm1

Now that it is imported, let's look at the various ways to access the help for the Write-Pretty function!

Get-Help

Get-Help Write-Pretty

Detailed

This will show us all of the available keywords.

Get-Help Write-Pretty -Detailed

Online

This will send us to the website (URI) defined in the link keyword.

Get-Help Write-Pretty -Online

Parameters

This one will display the information defined in the specified parameter keyword.

Get-Help Write-Pretty -Parameter prettyText
Get-Help Write-Pretty -Parameter textType

Examples

Get-Help Write-Pretty -Examples

This will display all the examples defined via the example keyword.

Adding comments to your code

Comments in your code provide messages to anyone editing the file. It can be useful for others that are reviewing your code (to see what it's doing, or more importantly, WHY it is doing what it is doing). You can add comments by simply starting the line with '#'

#This is an example comment. It will not be executed by PowerShell, but can be read by someone editing the script.

I like to add comments to the following areas of my code:

  • Code that performs a specific task that may seem out of place.
  • The beginning and end of functionsloops, ifs,  and switches
  • Any statements or block of code that are long, and it may be hard to see where they begin and end.

I added comments to the code we used in this post. Here is a snippet, which encompasses the Process {} statement in the Write-Pretty function. There are also some other comments inside this example as well.

Process { #Begin process for Write-Pretty function

        ForEach ($textItem in $prettyText) { #Begin ForEach loop to handle prettyText input (normal and via pipeline)

            Switch ($textType) { #Begin switch for textType argument

                {$_ -eq 'Random'} {

                    Write-Host -NoNewline "[" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15) 
                    Write-Host -NoNewline "R" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)
                    Write-Host -NoNewline "andom" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)
                    Write-Host -NoNewline "]" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)

                    #Split the text into an array, split by spaces. (Also turns it into a string before the split).
                    #We needed to turn it into a string in case the type wasn't string when it was received. Or else the .Split() method wouldn't work
                    $writeText  = $textItem.ToString().Split(' ')

                    #Change the text color for each element in the array.
                    ForEach ($text in $writeText) {

                        Write-Host -NoNewLine " $text" -ForegroundColor $(Get-Random -Minimum 1 -Maximum 15)

                    }

                    Write-Host `n
            
                }

                {$_ -eq 'Error'} {

                    Write-Host -NoNewline "[" -ForegroundColor White 
                    Write-Host -NoNewline "Error" -ForegroundColor Red -BackgroundColor DarkBlue
                    Write-Host -NoNewline "]" -ForegroundColor White 
                    Write-Host " $textItem" -ForegroundColor Red 

                }


                {$_ -eq 'Warning'} {

                    Write-Host -NoNewline "[" -ForegroundColor White
                    Write-Host -NoNewline "Warning" -ForegroundColor Yellow -BackgroundColor Blue
                    Write-Host -NoNewline "]" -ForegroundColor White
                    Write-Host " $textItem" -ForegroundColor Yellow


                }

                {$_ -eq 'Info' -or $_ -eq $null} {

                    Write-Host -NoNewline "[" -ForegroundColor White
                    Write-Host -NoNewline "Info" -ForegroundColor Green -BackgroundColor Black
                    Write-Host -NoNewline "]" -ForegroundColor White
                    Write-Host " $textItem" -ForegroundColor White

                }

                #The default option will simply write the text with no changes. This is if you do not specify a valid option for textType.
                Default { 
        
                    Write-Host $textItem
        
                }

            } #End switch for textType argument

        } #End ForEach loop to handle prettyText input (normal and via pipeline)

    } #End process for Write-Pretty function

Homework

I hope you've enjoyed the series so far! As always, leave a comment if you have any feedback or questions!

-Ginger Ninja

[Top]