Most infrastructure scripts start as a few lines pasted into a console. That is fine until the script runs against 300 servers, someone else has to use it, or it fails halfway through a change window. The concepts below are what turn a quick script into a tool you can trust. Every example uses a task infrastructure teams actually run: checking disk space and services across servers.
The examples are written for PowerShell 7, and most also work in Windows PowerShell 5.1. Where a feature needs PowerShell 7, I say so.
1. Advanced functions with CmdletBinding
Adding [CmdletBinding()] turns a plain function into an advanced function. It gains the common parameters every cmdlet has, such as -Verbose, -ErrorAction and -WarningAction, for free. Parameter attributes then do the checking you would otherwise write by hand.
function Get-DiskReport {
[CmdletBinding()]
param(
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName)]
[Alias('CN', 'Name')]
[string[]]$ComputerName,
[ValidateRange(1, 99)]
[int]$ThresholdPercent = 15
)
process {
foreach ($computer in $ComputerName) {
Write-Verbose "Checking $computer"
Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -ComputerName $computer |
ForEach-Object {
$free = [math]::Round($_.FreeSpace / $_.Size * 100, 1)
[pscustomobject]@{
ComputerName = $computer
Drive = $_.DeviceID
SizeGB = [math]::Round($_.Size / 1GB, 1)
FreePercent = $free
Low = $free -lt $ThresholdPercent
}
}
}
}
}
What the attributes give you:
- Mandatory prompts for a value instead of failing later with an empty variable.
- ValidateRange rejects a threshold of 0 or 150 before any server is touched.
ValidateSet,ValidatePatternandValidateScriptwork the same way. - Alias lets the function accept
-CNor-Name, which matters for the pipeline below.
2. The pipeline: begin, process and end
ValueFromPipeline means you can pipe server names straight in. ValueFromPipelineByPropertyName means you can pipe objects that have a ComputerName or Name property, such as the output of Get-ADComputer:
Get-ADComputer -Filter 'OperatingSystem -like "*Server*"' -SearchBase 'OU=Servers,DC=contoso,DC=com' |
Get-DiskReport -ThresholdPercent 10 |
Where-Object Low
A function that takes pipeline input has three blocks:
- begin runs once before the first item. Use it to open a connection, load a file or start a timer.
- process runs once for every item that arrives.
- end runs once at the end. Use it to write a summary or close connections.
If you put pipeline code outside a process block, the function only sees the last item. This is one of the most common bugs in shared scripts.
3. Output objects, not text
The function above returns [pscustomobject] rows, not formatted text. That one choice means the same output can be sorted, filtered, exported or sent on without changing the function:
$report = Get-Content .\servers.txt | Get-DiskReport
$report | Sort-Object FreePercent | Format-Table
$report | Export-Csv .\disk-report.csv -NoTypeInformation
$report | Where-Object Low | ConvertTo-Json
Keep Write-Host for messages meant only for a person watching the screen. Use Write-Verbose for progress detail that appears only with -Verbose, and Write-Warning for problems the reader should notice.
4. Error handling that actually stops
PowerShell has two kinds of errors. A terminating error stops the command. A non-terminating error writes a message and carries on with the next item. Most cmdlets raise non-terminating errors, which try and catch do not see. Add -ErrorAction Stop to turn them into terminating errors you can catch:
foreach ($computer in $ComputerName) {
try {
$disks = Get-CimInstance -ClassName Win32_LogicalDisk -Filter 'DriveType = 3' -ComputerName $computer -ErrorAction Stop
}
catch [Microsoft.Management.Infrastructure.CimException] {
Write-Warning "${computer}: cannot connect. $($_.Exception.Message)"
continue
}
catch {
Write-Error "${computer}: unexpected error. $_"
continue
}
finally {
Write-Verbose "Finished $computer"
}
# work with $disks here
}
Three details worth knowing:
- Catch specific exception types first and a general
catchlast. continueinside thecatchmoves on to the next server instead of ending the whole run.- Write
${computer}:rather than$computer:in a string. PowerShell reads$computer:as the start of a drive-qualified variable name and the line fails.
For scripts that change things, set $ErrorActionPreference = 'Stop' at the top so nothing fails silently.
5. WhatIf and Confirm with SupportsShouldProcess
Any function that changes a system should support -WhatIf and -Confirm, just like built-in cmdlets. Add SupportsShouldProcess and wrap each change in $PSCmdlet.ShouldProcess():
function Start-StoppedService {
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'Medium')]
param(
[Parameter(Mandatory)][string]$ComputerName,
[Parameter(Mandatory)][string[]]$Name
)
$stopped = Invoke-Command -ComputerName $ComputerName -ArgumentList (,$Name) -ScriptBlock {
param($Names)
Get-Service -Name $Names | Where-Object Status -eq 'Stopped'
}
foreach ($svc in $stopped) {
if ($PSCmdlet.ShouldProcess("$($svc.Name) on $ComputerName", 'Start service')) {
Invoke-Command -ComputerName $ComputerName -ArgumentList $svc.Name -ScriptBlock {
param($ServiceName)
Start-Service -Name $ServiceName
}
}
}
}
Start-StoppedService -ComputerName SRV01 -Name Spooler, W32Time -WhatIf
With -WhatIf, the function reports what it would do and changes nothing. In a change window, a -WhatIf run attached to the change record is far better evidence than "tested in my lab".
6. Splatting for readable commands
Long commands with many parameters are hard to read and easy to break. Splatting puts the parameters in a hashtable and passes them with @ instead of $:
$cim = @{
ClassName = 'Win32_LogicalDisk'
Filter = 'DriveType = 3'
ComputerName = $computer
ErrorAction = 'Stop'
}
Get-CimInstance @cim
Splatting also makes optional parameters easy. Add a key only when it is needed, for example if ($Credential) { $cim.Credential = $Credential }, instead of writing the command twice.
7. Running against many servers at once
Checking 300 servers one after another is slow. There are two good ways to run in parallel.
Invoke-Command already runs in parallel. Give it a list of computers and it connects to up to 32 at a time by default. You can change that with -ThrottleLimit:
$servers = Get-Content .\servers.txt
Invoke-Command -ComputerName $servers -ThrottleLimit 50 -ScriptBlock {
Get-CimInstance -ClassName Win32_OperatingSystem |
Select-Object CSName, LastBootUpTime
}
ForEach-Object -Parallel (PowerShell 7 only) runs a script block in several threads on your own machine. Variables from outside the block must be passed with $using::
$timeoutSeconds = 2
$results = $servers | ForEach-Object -ThrottleLimit 20 -Parallel {
[pscustomobject]@{
Server = $_
Online = Test-Connection -TargetName $_ -Count 1 -TimeoutSeconds $using:timeoutSeconds -Quiet
}
}
Use Invoke-Command when the work should happen on the remote servers. Use -Parallel for work that runs locally, such as network tests or API calls.
8. Credentials without passwords in scripts
A password in a script ends up in source control, email and backups. The SecretManagement module gives scripts one way to read secrets, whatever the vault behind it is:
Install-Module Microsoft.PowerShell.SecretManagement, Microsoft.PowerShell.SecretStore -Scope CurrentUser
Register-SecretVault -Name LocalStore -ModuleName Microsoft.PowerShell.SecretStore -DefaultVault
Set-Secret -Name OpsServiceAccount -Secret (Get-Credential)
# in the script
$cred = Get-Secret -Name OpsServiceAccount
Invoke-Command -ComputerName SRV01 -Credential $cred -ScriptBlock { hostname }
The same Get-Secret line can read from Azure Key Vault by registering a Key Vault vault instead of the local store, so the script does not change when it moves to an automation account or a build agent.
9. Strict mode, analysis and tests
Three habits catch most mistakes before a script reaches production:
Set-StrictMode -Version Latestat the top of a script turns a mistyped variable name into an error instead of a silent empty value.- PSScriptAnalyzer checks scripts against recommended practice:
Invoke-ScriptAnalyzer -Path .\DiskReport.ps1. It flags things like missingShouldProcesssupport, aliases in scripts and unused variables. - Pester runs tests. With
Mockyou can test the logic without touching a real server:
Describe 'Get-DiskReport' {
BeforeAll { . $PSScriptRoot/DiskReport.ps1 }
It 'flags a drive below the threshold' {
Mock Get-CimInstance { [pscustomobject]@{ DeviceID = 'C:'; Size = 100GB; FreeSpace = 5GB } }
(Get-DiskReport -ComputerName 'SRV01' -ThresholdPercent 10).Low | Should -BeTrue
}
It 'does not flag a healthy drive' {
Mock Get-CimInstance { [pscustomobject]@{ DeviceID = 'C:'; Size = 100GB; FreeSpace = 60GB } }
(Get-DiskReport -ComputerName 'SRV01').Low | Should -BeFalse
}
}
Run it with Invoke-Pester. A few tests like these mean the next person can change the script without fear.
10. Packaging as a module
When a few functions are used by the whole team, move them into a module: a folder with a .psm1 file holding the functions and a .psd1 manifest made with New-ModuleManifest. List the public functions in FunctionsToExport, keep helper functions private, and add comment-based help (.SYNOPSIS, .EXAMPLE) to each public function so Get-Help works. The team then runs Import-Module, and everyone uses the same tested version instead of their own copy.
A checklist for any script that changes systems
- Advanced function with validated parameters
- Objects as output,
Write-Verbosefor detail -ErrorAction Stopandtry,catch,finallyaround anything remote-WhatIfsupport throughSupportsShouldProcess- No passwords in the file
Set-StrictMode, a clean PSScriptAnalyzer run and a few Pester tests- A log, for example
Start-Transcript, kept with the change record
None of these is difficult on its own. Together they are the difference between a script that only its author dares to run and a tool the whole team relies on.