So … You Want to Write a PowerShell Script

With the Olympics over, my MMSMOA session drafts in, taxes done, and a thousand or so pages of classic sci-fi read it’s time to post something.

My degree is software engineering and for a brief period of time I was a professional developer. Instead of a profession it’s become more of a hobby that I enjoy doing just for the fun of it. When I was tasked with implementing System Center Configuration Manager at my organization I was glad to see that it came with a growing set of Powershell cmdlets and that an incredibly robust set of community tools and scripts were available. In fact, there were so many tools that a tool had to be developed just to keep track of the tools. That’s some straight Xzibit shit right there.

When I implement a community tool I can’t help but look at the code to figure out how it ticks. While almost every piece of code I’ve read has some novel idea implemented I frequently come across things that make me … sad. On some level, that’s to be expected with community content … few people are getting paid for this after all. However most of it is easily remedied or avoided which is to be the topic of this post.

Note: I want to make it clear that I am not a professional developer. I just play one on the internet. Nor am I trying to crap all over anyone’s work. My hope is that people steal my ideas and code below for their own scripts. Maybe even improve a thing or two and send it back to me. If you think I’ve gone astray feel free to call me out on my bullshit.

Think Before, or Shortly After, You Act

I am as guilty at this as anyone but my inclination is to always start coding right away and see what works. Before you get too far though decide if you are going to release your creation to the public. The sooner you decide this the better. If you plan to do so then you want to evaluate every decision in that light. While you know your plan for using the tool how might others use it? What kinds of configurations would others want? How can you make it work in not just your own environment but others as well? Do some research and see if there’s existing tools you might enhance or borrow from. Talk to people on Reddit, the Technet forums, or the degenerates on Slack about your idea to get some feedback. Trust me, it’s a lot easier to write code flexibly from the start than to have to go back and retrofit it later.

Path Handling

One of my biggest pet peeves is the handling of paths. Yes, it’s easy to just make a single string that points to a file or folder. No, that’s almost never the right way to do it.

Stop Hard-coding Paths. Just Stop.

When you start typing “C:\” just stop yourself right there. No path you choose is the path that everyone will want. Make it an optional parameter that defaults to what you think it should be. Don’t make people have to open up your script, find something buried a hundred lines down and change it.

Use Environment Variables

When setting defaults for your local paths never, I repeat never, use the drive letter. There’s a whole world of environment variables for you to explore. At the very least start with $env:SystemDrive. If you are using a configuration file instead of parameters then expand the string using $ExecutionContext.InvokeCommand.ExpandString($setting) to allow your users to specify paths with environment variables. To get a sorted list of the variables available run this powershell command:

Get-ChildItem Env: | Sort Name

Use Join-Path Instead of String Concatenation

Eventually you are going to want to join two strings together to form a path. There will be a strong pull to just jam them together using string concatenation (ex. $StringA + “\” + $StringB) or even worse “$StringA\$StringB”. Resist this foul temptation, my friend. Use Join-Path $StringA $StringB instead. Yes, I know that sometimes means nesting multiple Join-Path commands, but the time saved troubleshooting multiple slashes in who knows what direction is well worth it.

Declare Your Path’s Provider

This is specific to working with the Configuration Manager cmdlets; calling them requires that the location be set to the Configuration Manager PS-drive. While your location is set to this drive you will find that referring to ‘normal’ file paths doesn’t work reliably. You can flip back and forth between locations but a simpler solution is to preface your file paths with filesystem:: (ex “filesystem::$($env:SystemDrive)”)

Use the Script Folder As the Default Path

When creating default paths consider making them relative to the folder that the script is in which might not necessarily be the current directory. While PowerShell v3 introduced the automatic $PSScriptRoot variable if you have any Windows 7/Server 2008 R2 devices kicking around with PowerShell v2 you can use the following:

$global:ScriptPath = split-path -parent $MyInvocation.MyCommand.Definition

Use Test-Path Dammit!

Anytime you are going to reference a path … any … fricken … time … use Test-Path to make sure it actually exists. So easy to do … so often not done.

Support What-If

If you’re just doing a small script you might ignore this but if you’re writing anything of significance then you absolutely must support the -WhatIf parameter. If your script could in any way mess with someone else’s environment you owe it to them and yourself to support a safe testing method. By default any cmdlet called within your script will inherit that parameter and as long as it supports WhatIf as well it will make no permanent changes. If you’re doing something that doesn’t support the WhatIf parameter you can use the automatic variable $WhatIfPreference which acts as a boolean to avoid making changes. When calling a cmdlet you might want to override the current preference by calling it with -WhatIf:$False or -WhatIf:$True.

Logging: It is Not Optional. It is Not a Choice.

The goal of most Powershell scripts is to automate some process which in many cases will be scheduled to run without human interaction. You need to be creating a log file that contains enough information to remotely troubleshoot any issue that might arise. If you plan on releasing your solution you’re going to have someone reach out to you and tell you that it didn’t work. Unless you simply choose to ignore such requests it’s going to be tremendously helpful to have incredibly detailed logging. In other words, support incredibly detailed logging as a self-defense mechanism to make future you a happier person.

Support Verbose Logging

You might not want to write or log output for every line of significance in your script by default. Yet, being able to get that output will be incredibly helpful when remote troubleshooting in environments you know nothing about. For that reason be sure to liberally use Write-Verbose statements throughout your script. All the user has to do is run your script with the -Verbose parameter and they will now magically get incredibly detailed information to help troubleshoot. If you want to get real fancy you can support an even lower level of logging using Write-Debug.

Log Files: There is Only One True Format

Finally, we get to see some code is this blusterous post. If you are writing a script that in any way interacts with Configuration Manager there is but one acceptable logging format: CMTrace or whatever the heck the format is called that CMTrace.exe reads. Below is a function that I’ve cobbled together from several examples available online. Watch out for any others that write to the log file using the Add-Content cmdlet. Yes, that seems logical but that cmdlet will try and lock the file while writing and fail if the file is open … say in CMTrace. Which is why I am using the Out-File cmdlet with the -NoClobber parameter. This allows reading the file as it’s being written without any issue.

Function Add-TextToCMLog { ########################################################################################################## <# .SYNOPSIS Log to a file in a format that can be read by Trace32.exe / CMTrace.exe .DESCRIPTION Write a line of data to a script log file in a format that can be parsed by Trace32.exe / CMTrace.exe The severity of the logged line can be set as: 1 - Information 2 - Warning 3 - Error Warnings will be highlighted in yellow. Errors are highlighted in red. The tools to view the log: SMS Trace - http://www.microsoft.com/en-us/download/details.aspx?id=18153 CM Trace - Installation directory on Configuration Manager 2012 Site Server - <Install Directory>\tools\ .EXAMPLE Add-TextToCMLog c:\output\update.log "Application of MS15-031 failed" Apply_Patch 3 This will write a line to the update.log file in c:\output stating that "Application of MS15-031 failed". The source component will be Apply_Patch and the line will be highlighted in red as it is an error (severity - 3). #> ########################################################################################################## #Define and validate parameters [CmdletBinding()] Param( #Path to the log file [parameter(Mandatory=$True)] [String]$LogFile, #The information to log [parameter(Mandatory=$True)] [String]$Value, #The source of the error [parameter(Mandatory=$True)] [String]$Component, #The severity (1 - Information, 2- Warning, 3 - Error) [parameter(Mandatory=$True)] [ValidateRange(1,3)] [Single]$Severity ) #Obtain UTC offset $DateTime = New-Object -ComObject WbemScripting.SWbemDateTime $DateTime.SetVarDate($(Get-Date)) $UtcValue = $DateTime.Value $UtcOffset = $UtcValue.Substring(21, $UtcValue.Length - 21) #Create the line to be logged $LogLine = "<![LOG[$Value]LOG]!>" +` "<time=`"$(Get-Date -Format HH:mm:ss.fff)$($UtcOffset)`" " +` "date=`"$(Get-Date -Format M-d-yyyy)`" " +` "component=`"$Component`" " +` "context=`"$([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)`" " +` "type=`"$Severity`" " +` "thread=`"$($pid)`" " +` "file=`"`">" #Write the line to the passed log file Out-File -InputObject $LogLine -Append -NoClobber -Encoding Default -FilePath $LogFile -WhatIf:$False }

Error Handling: Please … Please Do This

Like logging, error handling is one of things that really separates one-offs from real code. I admit it’s something I usually leave until later on in the process. I’ll read through the script or function line by line and try to imagine the the wonderful ways it could go wrong and what information would help me pin down where the problem is and why it happened.

Try/Catch

Using Try/Catch is the simplest and most direct way to handle errors. Any piece of logic of any significance should be wrapped in its own try/catch block. Most crucial are calls to cmdlets that will invariably fail on you in fun and unexpected ways.

Make Your Error Logging Count

Now that you’ve caught and error what are you going to do? Most of the time you’re going to want to log something even if you don’t exit the script itself. Figure out some sort of standard that you like and just ruthlessly copy and paste that code into your catch blocks. Here’s what I like to use and has served me very well in remote troubleshooting. The first line is a message to yourself saying where/what went wrong. The second line is the actual error message. The last line will log the actual line of code that triggered the error. Making it clear to the user and therefore yourself the exact line that went haywire is golden.

Add-TextToCMLog $LogFile "Function SomethingOrOther: I dun goofed." $component 3 Add-TextToCMLog $LogFile "Error: $($_.Exception.Message)" $component 3 Add-TextToCMLog $LogFile "$($_.InvocationInfo.PositionMessage)" $component 3

Null Variables: They’re Going To Happen So Deal With It

Every single time you call a function or a cmdlet and assign the result to a variable ask yourself: am I absolutely certain that this returned something? I’ve seen so much code that makes a call to something and just assumes that nothing could ever go wrong. This goes double for calling any sort of external and/or remote resource like a web service that is absolutely guaranteed to fail you as some point. Although this method isn’t necessarily foolproof, PowerShell makes it dead simple to check: If ($MyVariable){Do Something}.

Think Really Hard About Your Parameters

Ok, so you’ve got a script that does a thing and you’re thinking that others might find it useful. Look at the list of parameters and ask yourself what is the minimum viable set whose values cannot be defaulted to something meaningful. Here’s a few common ones that I’ve seen that just should never be mandatory parameters.

Configuration and Log Files

If your script has many parameters you might decide to make use of a configuration file, almost always in XML. The path to the config file should not be mandatory, there’s simply no reason to do so. Sure, allow users to specify a path to their own config file but default to a file (just spit-balling here … config.xml) in the same folder as your script (see above for how to do this). The same goes for log files: if not given as a parameter then write it in the script’s folder.

Site Code And Site Server

This is specific to scripts written for Configuration Manager but nearly every such script I’ve seen requires that the user provides the site code and/or the site server. If your script is running against the Configuration Manager cmdlets then you are forced to run the script on a device that has the console installed. In almost every case that means that you have multiple ways of determining the site code and site server so stop forcing your users to enter it. The only time I’ve found these to be truly necessary is if the user has a CAS and therefore multiple sites or has never ran against the cmdlets and thus created the PS-Drive necessary to run them. Here’s a few functions I’ve written that attempt to determine the site code and site server.

#Taken from https://stackoverflow.com/questions/5648931/test-if-registry-value-exists Function Test-RegistryValue { ########################################################################################################## Param( [Alias("PSPath")] [Parameter(Position = 0, Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [String]$Path, [Parameter(Position = 1, Mandatory = $true)] [String]$Value, [Switch]$PassThru ) Process { If (Test-Path $Path) { $Key = Get-Item -LiteralPath $Path If ($Key.GetValue($Value, $null) -ne $null) { If ($PassThru) { Get-ItemProperty $Path $Value } Else { $True } } Else { $False } } Else { $False } } } Function Get-SiteCode { ########################################################################################################## <# .SYNOPSIS Attempt to determine the current device's site code from the registry or PS drive. .DESCRIPTION When ran this function will look for the client's site. If not found it will look for a single PS drive. .EXAMPLE Get-SiteCode #> ########################################################################################################## Try{ #Try getting the site code from the client installed on this system. If (Test-RegistryValue -Path "HKLM:\SOFTWARE\Microsoft\SMS\Identification" -Value "Site Code"){ $SiteCode = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\SMS\Identification" | Select-Object -ExpandProperty "Site Code" } ElseIf (Test-RegistryValue -Path "HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client" -Value "AssignedSiteCode") { $SiteCode = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\SMS\Mobile Client" | Select-Object -ExpandProperty "AssignedSiteCode" } #If the client isn't installed try looking for the site code based on the PS drives. If (-Not ($SiteCode) ) { #See if a PSDrive exists with the CMSite provider $PSDrive = Get-PSDrive -PSProvider CMSite -ErrorAction SilentlyContinue #If PSDrive exists then get the site code from it. If ($PSDrive.Count -eq 1) { $SiteCode = $PSDrive.Name } } Return $SiteCode } Catch{ Return } } Function Get-SiteServer { ########################################################################################################## <# .SYNOPSIS Attempt to determine the current device's site server. .DESCRIPTION When ran this function will look for the client's site server. .EXAMPLE Get-SiteServer #> ########################################################################################################## Try{ $Sitecode = Get-SiteCode If ($Sitecode){ #See if a PSDrive exists with the CMSite provider and name $PSDrive = Get-PSDrive -PSProvider CMSite -Name $Sitecode -ErrorAction SilentlyContinue Return (Get-PSDrive -PSProvider CMSite -Name DUS).Root } Else{ #If no site code was found then return nothing. Return } } Catch { Return } }

Putting it All Together

So what does this all look like when put together? The sad reality is that writing quality code is more about dealing with what could go wrong than it is about pounding out some super-awesome piece of logic that does something cool. So instead of a one-liner like this

Get-Content 'C:\Windows\CCM\Logs\Wuauhandler.log'

you end up with 31 lines:

[CmdletBinding()] Param( $WUAUHandlerLog = "filesystem::$(Join-Path $env:windir 'CCM\Logs\Wuauhandler.log')" ) Write-Verbose "WUAUHandler Path: $WUAUHandlerLog" #Test if the wuauhandler log file exists. If (Test-Path $WUAUHandlerLog -PathType Leaf){ Try{ $WUAUHandlerLogData = Get-Content 'C:\Windows\CCM\Logs\Wuauhandler.log' } Catch{ Add-TextToCMLog $LogFile "Could not get content from the wuauhandler.log file." $component 3 Add-TextToCMLog $LogFile "Error: $($_.Exception.Message)" $component 3 Add-TextToCMLog $LogFile "$($_.InvocationInfo.PositionMessage)" $component 3 Exit } } Else { Add-TextToCMLog $LogFile "Failed to find the wuauhandler.log file." $component 3 Exit } #Make sure there was data in the wuauhandler log. If (!($WUAUHandlerLogData)) { Write-Verbose "There was no content in the wuauhandler.log file." Exit }

So yea … it’s shit-ton of work to put out quality code and when developing something that you release for free to the community it can be hard to work up the energy to do it right. I want to encourage you to fight against this tendency. Make beautiful, resilient, and easy to debug code. If not, at least put that shit on Github and accept my pull request that remedies your laziness. I’m not deploying anything to thousands of boxes that isn’t done right.