If you’ve ever inherited a script or set of PowerShell scripts, you probably know the frustration. You have a specific way of coding honed over years and years of experience and you come across a…..let’s say monstrosity.
You inherit the ugliest, most feared spawn of Satan PowerShell script you’ve ever seen. I’m talking these scripts break every rule in the book.
Don’t be scared off! Everything’s fixable; even spaghetti scripts made by a newbie that thinks they know everything.
Stay with me.
Build Pester Tests to Validate Environment Changes
As you might know if you’ve read The Pester Book, you can build tests in various “layers”.
- Unit tests test code execution (think what parameters are passed to functions, the branch the code follows in an if/then construct, etc).
- Integration tests test the changes the code makes. For example, testing whether
Set-Content
added the appropriate string to a file orNew-Item
adding that expected registry key. If the script opens a firewall post, doesGet-NetFirewallRule
return the expected output? - Acceptance tests that test the end goal. If your script opens a firewall port, you’d attempt to connect to that port with
Test-NetConnection
and validate the service is running. Acceptance tests confirm the end goal.
Since you’ll be drastically changing the code eventually, skip the unit tests. Likewise, at this point, you’re not actually testing if the mess code does what it should (the original author should have done that).
You need to build an extensive set of integration tests to confirm all of the changes this code makes. You need to build tests for every registry key or value it changes, files it moves, updates it installs, everything!
Why only integration tests? Because you must ensure once you start making a lot of changes to the original codebase you have a button you can press to ensure you’ve got everything. WIthout integration tests, you could refactor the entire codebase, forget 20 different things, your code produces no errors and you move on.
A few months down the road, you’ll then run into an obscure edge case that, come to find out, you just forget to add that exception in your refactored code.
Gather Up the Mess into One Place
Just like Marie Kondo says, you first need to collect all the mess and get it into one place. A tidy PowerShell script always sparks joy for me! If the mess is split up into scripts across various file and folders each referencing each other, remove all of those “links”
Bring everything into a single PowerShell script. By combining all of the code first, you’ll be able to find/replace strings easier, not have to follow rabbit trails of script executions and your sanity will be saved from switching tabs all of the time.
If you’ve got a mess that consists of 10 scripts calling other scripts that call module functions, combine everything into a single PS1 script. At this stage, you’re not concerned with keeping a working solution in place. Just dump everything into a single script.
Use Regions
One great way to keep some kind of history of where the original code came from in a script is to use regions. Regions all you to create “buckets” of code. You can then name each region with a reference to where the code came from.
For example:
#region bad_script1.ps1
## Contents of bad_script1.ps1 here
#endregion
#region bad_script2.ps1
## Contents of bad_script2.ps1 here
#endregion
Remove Dependencies
Next, continue simplifying the mess script as much as possible. To do that, you need to remove as many dependencies as possible. Dependencies include parameters, variables in the code along with any files referenced, databases, etc.
Remove Variables and Parameters
Assign values to all variables declared in the PowerShell script or just remove them entirely. If you’re working with a script that has potentially hundreds of variables, you need to simplify troubleshooting. You need to make it as easy as possible to follow the code. Variables and other references are great in the finished product but you need to simplify the code at this point.
Remove all parameters but be sure to document them. You may need to reintroduce these parameters in your end product.
If, for example, you’ve had to consolidate many PowerShell scripts together you probably have lines that look the following code snippet:
#region bad script1
param(
[Parameter()]
[string]$Param1
)
$variable =
## code here
#endregion
#region bad script2
param(
[Parameter()]
[string]$Param2
)
## code here
#endregion
Remove all of the parameter blocks and instead, assign a static value to those variables as shown below.
[string]$ServerName = 'MYSWEETSNOWFLAKE'
## code here
#endregion
[string]$IpAddress = '192.168.0.1'
## code here
#endregion
Bring any External Dependencies Closer
You won’t be able to remove all dependencies but be sure to simplify them as much as possible. For example, if that script references various files on different servers, bring them to the same folder the script is in.
If the PowerShell script is querying a database, try to get a local copy running. The point of removing dependencies is, again, simplifying the code you’re going to have to follow.
Your single, consolidated mess script should have absolutely no parameters or depend on any other outside system to run. To test it, you should be able to run .\messscript.ps1 with no parameters and it should run by a single-use case.
Turn all Logging/Debugging Info into Comments
The next step in simplifying the mess script is to remove all logging or debugging code. You’ll probably see a lot of Write-Host
, Write-Verbose
and perhaps even a custom Write-Log
function. Either comment this out or remove them entirely.
You could remove logging and debugging lines completely but I’d recommend commenting them out. You might need this information to understand what the code is doing at some point. Once you’re comfortable with the code flow, you can remove the comments.
Logging and debugging lines don’t contribute anything to the functionality of the script. Instead, they complicate it and make it take longer to decipher.
Build a Test Environment and Fail All Tests
Whether the original solution made changes to a bunch of virtual machines (VMs), cloud resources, or anything else, you need a clean test environment.
Build a test environment that contains all configuration items that are not the same as what the mess is changing. You need an environment that contains the opposite of everything the mess is changing so you can then compare afterward.
Once the test environment is built, run your integration test suite. Do all of the tests fail? Perfect! You now know your environment has no configuration applied the mess code is supposed to change.
Important: Don’t forget to ensure all integration tests fail in your test environment. This is important! If not, a configuration item may already be applied and you’ll have no way to know if it was just that way to begin with or your refactored code updated it.
You now have a pristine environment set up to compare changes to.
Get the Mess Working Again
You should now have all of the PowerShell scripts and modules bundled into a single (probably multi-thousand line) PS1 script and a set of integration tests that are all passing. Now what?
It’s now time to begin fixing variable references, parameter values, command references and other logic preventing this solution from working in a single PS1 script.
Don’t try to begin making this solution pretty right now. Just try to get your consolidated, smorgasbord of code working exactly like it was before albeit from a single script.
Your goal at this stage is to just make your single script behave just as it did when it was split up into 1,000 pieces. You need a consolidated mess starting point.
Keep making changes and running your test suite until all tests pass.
The Hard Part: Refactoring PowerShell Script
Now it’s time to begin refactoring the code. This is the fun part! Depending on what your mess script(s) are doing will greatly depend on how you refactor but below you’ll find a few suggestions.
Build a Structure for Various Tasks
Every large script has various “tasks” it performs inside of it. Perhaps it’s getting NIC properties, building a VM, creating a mailbox, removing users, whatever. The script completes tasks. Right now, those tasks are probably all merged together.
Create a new script(s) and split out those tasks into single PowerShell scripts or perhaps regions in another clean script.
Splitting out these tasks better will help you understand what the code is going while making some progress on the code refactoring.
Build Configuration Data
At this point, you have everything in a single PS1 script. That’s not good. You need to begin modularizing this script a bit. For your first task, build a configuration data store. What’s a configuration datastore? It’s a generic term to store configuration items that may change over time.
This section is where those parameters you documented earlier come into play.
Perhaps you have a script that provisions a new server from Windows deployment all of the way up to configuring the OS. In the previous mess scripts, you found various parameters like ComputerName
, IpAddress
, RunPatching
, etc. These parameters changed the behavior of the mess scripts. You might want to keep them.
Instead of reintroducing these parameters at this stage, instead, create a configuration datastore to store all of them. For example, you could create a file called configuration.psd1 in the same folder as the script.
The configuration.psd1 file might look like the following:
@{
'DefaultServerConfig' = @{
'ServerName' = 'SRV1'
'ComputerNamePrefix' = 'xxxx'
'IpAddress' = $null
'RunPatching' = $true
}
}
Then, inside of the mess script, you could add a function called Get-Configuration
that reads the configuration and assigns it to a variable.
function Get-Configuration {
param()
Import-PowerShellDataFile -Path "$PSScriptRoot\configuration.psd1"
}
$configuration = Get-Configuration
Now replace all of those parameter references that were in the other scripts with references to the solution’s configuration like below.
$Param1 = $configuration.ServerName
## code here
#endregion
$Param2 = $configuration.IpAddress
## code here
#endregion
You don’t have to use a PowerShell data file. You could use JSON, YAML or XML (if you like pain).
You’re now splitting out the configuration from the logical code.
Eliminate Redunduncies
Next item on the agenda is redundancies. There are many ways redundancies can creep into code. One of the most common ways is neglecting to use loops.
If, for example, you see some lines that look like below:
Do-Thing -ComputerName XXX -Thing yyyy
Do-Thing -ComputerName rrr -Thing hhhh
Remove the need to call a command twice and instead use parameter splatting and a foreach loop to simplify it.
$paramSets = @(
@{
ComputerName = 'XXX'
Thing = 'yyyy'
}
@{
ComputerName = 'rrr'
Thing = 'hhhh'
}
}
foreach ($set in $paramSets) {
Do-Thing @set
}
The above code snippet is just one, single scenario. There are countless ways to remove redundant code. Adding in loops is probably one of the easiest ways to reduce code length and simplify things.
Modularize!
Being apart that monolithic mess script into small pieces that all work together and build a standard set of modules or functions they all can share.
- Create one or more PowerShell modules and convert the scripts to use functions inside of them.
- Create small, one-purpose functions for all of the tasks.
How You Know When You’re Done
How do you know when you’re done? If you answer Yes to all of these questions, you’re done. Pat yourself on the back.
- Are you happy with your new solution?
- Have you followed all of the tips in this article?
- Do all of the integration tests pass that you built earlier both after you run the old solution and new solution in a fresh test environment?
If you answered Yes to all of these questions, congratulations, you’ve done it!