07 · Building PowerShell-based DevOps Tools¶
Everything so far has built toward this: a script meant to be run by other people (or other pipelines) as a real CLI tool, not read as source code. That means parameter sets that make invalid combinations impossible, predictable exit codes, and structured logging — the difference between a script and a tool.
Parameter sets: making invalid combinations unrepresentable¶
[CmdletBinding(DefaultParameterSetName = 'Status')]
param(
[Parameter(ParameterSetName = 'Deploy', Mandatory)]
[switch]$Deploy,
[Parameter(ParameterSetName = 'Deploy', Mandatory)]
[Parameter(ParameterSetName = 'Rollback', Mandatory)]
[string]$Environment,
[Parameter(ParameterSetName = 'Rollback', Mandatory)]
[switch]$Rollback,
[Parameter(ParameterSetName = 'Status')]
[switch]$Status
)
switch ($PSCmdlet.ParameterSetName) {
'Deploy' { "Deploying to $Environment..." }
'Rollback' { "Rolling back $Environment..." }
'Status' { "Checking status of all environments..." }
}
$Environment belongs to both the Deploy and Rollback sets (two
[Parameter(...)] attributes on one parameter), while -Deploy,
-Rollback, and -Status each anchor their own set — PowerShell figures
out which set is active from which switches were passed, and
$PSCmdlet.ParameterSetName tells you which one won. Crucially, this
makes -Deploy -Rollback together a parse-time error, not something
your code has to detect and reject manually — the invalid combination
literally cannot bind.
Passing -Deploy without the also-mandatory -Environment triggers
PowerShell's normal mandatory-parameter prompt:
cmdlet deploycli.ps1 at command pipeline position 1
Supply values for the following parameters:
Environment:
That interactive prompt is exactly why every unattended/CI invocation of a tool like this must pass every mandatory parameter explicitly — a missing one doesn't fail cleanly by default, it hangs waiting for input that will never come in a non-interactive context. (Redirecting empty input, as CI often does implicitly, turns that hang into the "missing mandatory parameters" error instead — better, but still not as clean as never triggering the prompt at all.)
Exit codes and structured logging¶
[CmdletBinding()]
param(
[Parameter(Mandatory)]
[ValidateSet('dev','staging','prod')]
[string]$Environment,
[switch]$DryRun
)
$ErrorActionPreference = 'Stop'
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
Write-Output $line
}
try {
Write-Log "Starting deploy to $Environment (DryRun=$DryRun)"
if ($DryRun) {
Write-Log "Dry run - no changes made"
exit 0
}
if ($Environment -eq 'staging') {
throw "Simulated failure: staging artifact not found"
}
Write-Log "Deploy to $Environment succeeded"
exit 0
} catch {
Write-Log "Deploy failed: $($_.Exception.Message)" -Level ERROR
exit 1
}
2026-08-26 11:04:33 [INFO] Starting deploy to dev (DryRun=True)
2026-08-26 11:04:33 [INFO] Dry run - no changes made
2026-08-26 11:04:34 [INFO] Starting deploy to staging (DryRun=False)
2026-08-26 11:04:34 [ERROR] Deploy failed: Simulated failure: staging artifact not found
1.
Three habits that turn a script into something automation can rely on:
$ErrorActionPreference = 'Stop'at the top means a non-terminating error from a cmdlet inside thetrystill gets caught, rather than printing a warning and continuing past a real failure — the single most common reason a "successful" CI step actually did nothing.Write-Logwith a level and timestamp on every line — the exact same pattern module 09 (Logging & Observability) builds out further, but the core idea starts here: consistent structure means downstream tooling (log aggregation,Select-String, alerting) can parse it reliably instead of scraping free-form text.- Explicit
exit 0/exit 1— never rely on PowerShell's own fall-through exit code for a tool meant to be invoked by another system; state the outcome as a number every time, on every code path.
Designing for the caller, not just yourself¶
A DevOps tool gets invoked by people who didn't write it and by pipelines that can't ask it questions — a few conventions make that much smoother:
-DryRun/-WhatIfon anything destructive, so a caller can verify intent before committing to it (this mirrorsSupportsShouldProcessfrom earlier modules, but a simple-DryRunswitch works fine for a standalone tool too).-Verbosefor detail, plain output for the result — the default, non-verbose run should be quiet and just report success/failure; put step-by-step detail behindWrite-Verboseso both a human debugging interactively and a quiet CI log get what they each actually want.- Consistent noun-verb naming across your own tools —
deploy-tool.ps1 -Environment prod,rollback-tool.ps1 -Environment prod,status-tool.ps1reads far more predictably as a family than three scripts with unrelated argument conventions.
Cheat sheet¶
| Feature | Purpose |
|---|---|
Multiple [Parameter(ParameterSetName=...)] per param |
share a parameter across sets |
$PSCmdlet.ParameterSetName |
detect which set actually bound |
| Mandatory param missing, no input redirected | hangs on a prompt — always pass everything explicitly in automation |
$ErrorActionPreference = 'Stop' |
non-terminating errors become catchable |
Write-Log with level + timestamp |
structured, parseable output |
Explicit exit 0 / exit 1 |
reliable outcome signal for any caller |
-DryRun / -WhatIf |
let callers verify intent before committing |
-Verbose for detail, quiet default output |
serves both interactive and CI use |
How It Actually Works¶
DevOps-facing PowerShell tools that wrap other CLIs (az, kubectl,
docker) work by invoking them as native external commands — when the
engine's command resolver can't find a matching function, alias, or
cmdlet, it falls through to searching PATH for an executable, then
launches it as a genuine child process via System.Diagnostics.Process
with the remaining tokens passed as raw argument strings. This is the
mechanical reason argument quoting for native commands is fragile in a
way cmdlet parameters aren't: cmdlet parameter binding understands
PowerShell's own type system, but a native executable only ever receives
a flat array of strings assembled by the OS's process-creation API, so
values containing spaces, quotes, or the ampersand need platform-specific
escaping (--% batch-parsing-stop-mode existing specifically to hand off
raw, unescaped argument text when PowerShell's own tokenizer would
otherwise interfere).
Output captured from these native tools (kubectl get pods -o json |
ConvertFrom-Json) has none of the ETS-object richness native cmdlets
provide — it's plain text on stdout, so any structure a DevOps tool
built around it has to explicitly re-establish, either by requesting
JSON output from the CLI itself and parsing it, or by regex-parsing
formatted table output (fragile, since a CLI's human-readable table
format is not a stable contract the way its JSON schema usually is).
$LASTEXITCODE and stderr are the only structured signal a wrapped
native tool provides back to PowerShell — unlike cmdlets, whose failures
integrate into $Error/-ErrorAction, a failing native command doesn't
throw a terminating error PowerShell recognizes on its own (with pwsh
defaults) unless you explicitly check $LASTEXITCODE after the call,
which is why "designing for the caller" in a DevOps wrapper function
means translating that raw exit-code/stderr contract into PowerShell's
richer error model (throw/Write-Error with proper ErrorRecords)
rather than leaking the native tool's flat signal straight through.
Exercise¶
Build a db-tool.ps1 with three parameter sets — Backup (requires
-Database), Restore (requires -Database and -BackupFile), and
List (no extra parameters, the default set) — each writing structured
log lines via a shared Write-Log function and exiting with the correct
code on simulated success/failure. Confirm $PSCmdlet.ParameterSetName
correctly resolves for each combination, and confirm an invalid
combination (like -Database and -BackupFile together with no
-Restore context) is rejected at parse time rather than needing manual
validation in the body.