Skip to Content
CheatsheetsPowerShell

PowerShell Cheat Sheet

PowerShell 7+ patterns for Azure automation, DevOps pipelines, scripting, and systems administration. All examples are cross-platform unless noted.

Versions: PowerShell 7.2+ / Az module 12+ / Pester 5.6+ / Microsoft.Graph 2.x. Windows-only cmdlets (Out-GridView, registry access, COM objects) are noted inline where they appear.

Last reviewed: May 2026


Script Boilerplate

Strict mode and error preferences

PowerShell
#!/usr/bin/env pwsh
#Requires -Version 7.2
#Requires -Modules @{ ModuleName = 'Az'; ModuleVersion = '12.0.0' }
 
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$WarningPreference     = 'Continue'
$VerbosePreference     = 'SilentlyContinue'
# Set-PSDebug -Trace 2   # line-by-line trace for debugging

Standard script parameter block

PowerShell
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param (
    [Parameter(Mandatory, HelpMessage = 'Azure subscription ID')]
    [ValidatePattern('^[0-9a-f]{8}-([0-9a-f]{4}-){3}[0-9a-f]{12}$')]
    [string]$SubscriptionId,
 
    [Parameter(Mandatory)]
    [ValidateNotNullOrEmpty()]
    [string]$ResourceGroupName,
 
    [Parameter()]
    [ValidateSet('dev', 'staging', 'prod')]
    [string]$Environment = 'dev',
 
    [Parameter()]
    [switch]$Force
)

Trap and cleanup pattern

PowerShell
$script:CleanupActions = [System.Collections.Generic.List[scriptblock]]::new()
 
function Register-Cleanup ([scriptblock]$Action) {
    $script:CleanupActions.Add($Action)
}
 
trap {
    Write-Error "Unhandled error: $_"
    foreach ($action in $script:CleanupActions) { & $action }
    exit 1
}

Logging

🛠️ Deeper reference - covers stream selection (Write-Information vs Write-Host vs Write-Verbose), levelled wrappers, JSON output for log shippers, and stream redirection for CI. For quick lookups, jump to “Use the right stream”.

PowerShell’s Write-* cmdlets already form a layered logging system - the trick is using them correctly. Write-Host writes straight to the host (not pipeable, not capturable); for anything that should respect verbosity preferences or be captured, use the typed streams.

Use the right stream

CmdletStream #When to useHonours preference var
Write-Output1 (stdout)The script’s actual return datan/a
Write-Error2Recoverable failure - the operation didn’t work$ErrorActionPreference
Write-Warning3Something to flag but execution continues$WarningPreference
Write-Verbose4Detailed diagnostics, off by default$VerbosePreference / -Verbose
Write-Debug5Developer-only deep diagnostics$DebugPreference / -Debug
Write-Information6Structured info events - preferred over Write-Host$InformationPreference / -InformationAction
Write-Host6 (info)UI output only - colour, banners, promptsNo

Rule of thumb: Write-Information for normal log lines, Write-Verbose for diagnostics, Write-Warning for things you want the user to see, Write-Error for failures. Save Write-Host for colour/banners that aren’t real log data.

Advanced function with proper streams

[CmdletBinding()] gives every function -Verbose, -Debug, -InformationAction, etc. for free. Use them.

PowerShell
function Deploy-Stack {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Name,
        [Parameter()][string]$Environment = 'dev'
    )
 
    Write-Verbose "Deploy-Stack called: Name=$Name Env=$Environment"
    Write-Information "Starting deploy for $Name" -InformationAction Continue
 
    try {
        # ... real work ...
        Write-Information "Deploy of $Name succeeded" -InformationAction Continue
    }
    catch {
        Write-Warning "Deploy of $Name failed - rolling back"
        Write-Error  $_ -ErrorAction Stop
    }
}
 
# Default - quiet
Deploy-Stack -Name app
 
# Show diagnostics
Deploy-Stack -Name app -Verbose

Leveled wrapper - timestamped, stream-correct

PowerShell
function Write-Log {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory, Position = 0)]
        [ValidateSet('Debug', 'Verbose', 'Info', 'Warning', 'Error')]
        [string]$Level,
 
        [Parameter(Mandatory, Position = 1)]
        [string]$Message,
 
        [hashtable]$Context
    )
 
    $ts   = (Get-Date).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
    $ctx  = if ($Context) { ' ' + (($Context.GetEnumerator() | ForEach-Object { "$($_.Key)=$($_.Value)" }) -join ' ') } else { '' }
    $line = "$ts  [$Level]  $Message$ctx"
 
    switch ($Level) {
        'Debug'   { Write-Debug       $line }
        'Verbose' { Write-Verbose     $line }
        'Info'    { Write-Information $line -InformationAction Continue }
        'Warning' { Write-Warning     $line }
        'Error'   { Write-Error       $line }
    }
}
 
Write-Log Info    'deploy started'   -Context @{ env = 'prod'; rg = 'rg-app' }
Write-Log Warning 'using fallback region'
Write-Log Error   'apply failed'     -Context @{ exit_code = $LASTEXITCODE }

Structured (JSON) logging for log shippers

For containerised PowerShell or anywhere a log shipper parses output, emit one JSON object per line on stdout. Use ConvertTo-Json -Compress so each event stays on one line.

PowerShell
function Write-LogJson {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('Debug','Info','Warning','Error')] [string]$Level,
        [Parameter(Mandatory)][string]$Message,
        [hashtable]$Context = @{}
    )
 
    $record = [ordered]@{
        ts      = (Get-Date).ToUniversalTime().ToString('o')
        level   = $Level
        host    = [Environment]::MachineName
        pid     = $PID
        message = $Message
    }
    foreach ($k in $Context.Keys) { $record[$k] = $Context[$k] }
 
    # -Compress = single-line; -Depth lets nested hashtables serialise.
    $record | ConvertTo-Json -Compress -Depth 10
}
 
Write-LogJson Info  'deploy started' -Context @{ env = 'prod'; correlation_id = $cid }
Write-LogJson Error 'apply failed'   -Context @{ exit_code = $LASTEXITCODE }

Capturing streams in scripts that call other scripts

PowerShell
# Capture every stream into one variable (PowerShell 5+).
$all = & ./deploy.ps1 -Name app *>&1
 
# Or redirect by stream:
&  ./deploy.ps1 -Name app 6>info.log 3>warn.log 2>error.log
 
# In CI, send everything except real return data to stderr so the agent surfaces it:
&  ./deploy.ps1 -Name app 4>&2 5>&2 6>&2

Module-level: PSFramework if you need more

PSFramework (Install-Module PSFramework -Scope CurrentUser) is the de-facto third-party logger - it provides log providers (file, JSON, Splunk, Azure Log Analytics), automatic rotation, structured fields, and runspace-safe writes. Worth pulling in for anything beyond simple scripts.

PowerShell
Import-Module PSFramework
 
Set-PSFLoggingProvider -Name 'logfile' `
    -FilePath  'C:\logs\deploy-%date%.json' `
    -FileType  Json `
    -Enabled   $true
 
Write-PSFMessage -Level Important -Message 'deploy started' -Tag 'deploy' `
    -Data @{ env = 'prod'; rg = 'rg-app' }

Sensible defaults checklist

  • Always declare [CmdletBinding()] so callers get -Verbose / -InformationAction for free.
  • Set $ErrorActionPreference = 'Stop' so terminating errors propagate predictably.
  • Prefer Write-Information over Write-Host for log lines; reserve Write-Host for coloured UI output.
  • Write-Verbose for any diagnostic the user would only want when troubleshooting.
  • Use Write-Error -ErrorAction Stop (or throw) inside catch blocks - bare Write-Error is non-terminating.
  • In CI/containers, emit JSON via Write-LogJson so log shippers can parse fields.

Core Language

String formatting

PowerShell
$name    = 'World'
$count   = 42
$pi      = 3.14159
 
# f-string style (PowerShell 7+, not real f-strings - just expression interpolation)
"Hello, $name! Count: $count"
"Pi is $($pi.ToString('F2'))"
 
# -f operator (format string)
"Hello, {0}! Count: {1:N0}" -f $name, $count
"Hex: 0x{0:X4}" -f 255
 
# Here-string (preserves whitespace and newlines)
$json = @"
{
  "name": "$name",
  "count": $count
}
"@
 
# Single-quoted here-string (no interpolation)
$literal = @'
No $interpolation here
'@

Null coalescing and ternary

PowerShell
# Null-coalescing (PS 7+)
$value = $env:MY_VAR ?? 'default'
 
# Null-coalescing assignment
$config ??= [hashtable]::new()
 
# Ternary
$label = ($count -gt 10) ? 'many' : 'few'
 
# Null conditional member access
$length = $someString?.Length
$item   = $array?[0]

Collections

PowerShell
# Typed arrays
[int[]]$numbers  = 1, 2, 3, 4, 5
[string[]]$names = @('Alice', 'Bob', 'Charlie')
 
# ArrayList (mutable)
$list = [System.Collections.Generic.List[string]]::new()
$list.Add('item1')
$list.AddRange([string[]]@('item2', 'item3'))
$list.Remove('item1')
 
# Hashtable
$map = @{
    Key1 = 'Value1'
    Key2 = 42
}
$map['Key3'] = 'added'
$map.Remove('Key1')
 
# Ordered hashtable (preserves insertion order)
$ordered = [ordered]@{
    First  = 1
    Second = 2
    Third  = 3
}
 
# Dictionary
$dict = [System.Collections.Generic.Dictionary[string, int]]::new()
$dict['a'] = 1
$dict.TryGetValue('b', [ref]$null)

PSCustomObject

PowerShell
# Literal
$obj = [PSCustomObject]@{
    Name   = 'myresource'
    Region = 'uksouth'
    Tags   = @{ env = 'prod' }
}
 
# Add members dynamically
$obj | Add-Member -NotePropertyName 'Id' -NotePropertyValue 'abc123'
$obj | Add-Member -MemberType ScriptProperty -Name 'Upper' -Value { $this.Name.ToUpper() }
 
# From hashtable list
$rows = @(
    @{ Name = 'rg-prod'; Location = 'uksouth' }
    @{ Name = 'rg-dev';  Location = 'ukwest' }
) | ForEach-Object { [PSCustomObject]$_ }

PSCustomObject - patterns and idioms

Building arrays from pipelines

PowerShell
# Select-Object creates a PSCustomObject directly (no explicit cast needed)
$report = Get-AzResource | Select-Object Name, ResourceType,
    @{ Name = 'RG'; Expression = { $_.ResourceGroupName } },
    @{ Name = 'AgeHours'; Expression = { ((Get-Date) - $_.ChangedTime).TotalHours -as [int] } }
 
# ForEach-Object for richer logic
$report = Get-AzResourceGroup | ForEach-Object {
    $resources = Get-AzResource -ResourceGroupName $_.ResourceGroupName
    [PSCustomObject]@{
        Name      = $_.ResourceGroupName
        Location  = $_.Location
        Count     = $resources.Count
        HasAppSvc = $resources.ResourceType -contains 'Microsoft.Web/sites'
    }
}

Nested objects

PowerShell
$config = [PSCustomObject]@{
    Database = [PSCustomObject]@{
        Host    = 'db.example.com'
        Port    = 5432
        TlsMode = 'require'
    }
    App = [PSCustomObject]@{
        Port    = 8080
        Workers = 4
    }
}
 
$config.Database.Host   # db.example.com
$config.App.Port        # 8080

Converting to/from hashtable

PowerShell
# PSCustomObject → hashtable
function ConvertTo-Hashtable {
    param ([Parameter(Mandatory, ValueFromPipeline)][PSCustomObject]$Object)
    process {
        $ht = [ordered]@{}
        foreach ($prop in $Object.PSObject.Properties) {
            $ht[$prop.Name] = $prop.Value
        }
        $ht
    }
}
$ht = $obj | ConvertTo-Hashtable
 
# Hashtable → PSCustomObject (cast)
$obj = [PSCustomObject]$ht
 
# Round-trip via JSON (handles nested objects too)
$json = $obj | ConvertTo-Json -Depth 10
$copy = $json | ConvertFrom-Json

Type names and default display

PowerShell
# Assign a type name - shows in Get-Member and Format-* output
$obj.PSObject.TypeNames.Insert(0, 'LibreDevOps.DeployResult')
 
# Add a custom display property with Update-TypeData (session-scoped)
Update-TypeData -TypeName 'LibreDevOps.DeployResult' `
    -MemberType ScriptProperty `
    -MemberName 'Summary' `
    -Value { "[$($this.Status)] $($this.Name) @ $($this.Region)" } `
    -Force
 
# Define default display columns (analogous to Format.ps1xml)
Update-TypeData -TypeName 'LibreDevOps.DeployResult' `
    -DefaultDisplayPropertySet 'Name', 'Status', 'Region', 'Duration' `
    -Force
 
$obj.Summary
$obj | Format-Table   # uses DefaultDisplayPropertySet columns

Sorting, grouping, and comparing

PowerShell
# Sort by multiple properties
$report | Sort-Object Location, Name
 
# Group and summarise
$report | Group-Object Location | ForEach-Object {
    [PSCustomObject]@{ Region = $_.Name; Count = $_.Count }
}
 
# Diff two object arrays on specific properties
Compare-Object $before $after -Property Name, Status |
    Where-Object SideIndicator -eq '=>' |   # new/changed
    Select-Object Name, Status

Using PSCustomObjects as function output

PowerShell
function Get-DeployStatus {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param ([Parameter(Mandatory)][string[]]$ResourceGroup)
 
    foreach ($rg in $ResourceGroup) {
        $resources = Get-AzResource -ResourceGroupName $rg -ErrorAction SilentlyContinue
        $obj = [PSCustomObject]@{
            ResourceGroup = $rg
            ResourceCount = $resources.Count
            Status        = if ($resources.Count -gt 0) { 'Active' } else { 'Empty' }
            CheckedAt     = [datetime]::UtcNow
        }
        $obj.PSObject.TypeNames.Insert(0, 'LibreDevOps.DeployStatus')
        $obj   # emit to pipeline - do NOT use return inside a foreach pipeline emitter
    }
}
 
# Caller can pipe, sort, export, or filter naturally
Get-DeployStatus -ResourceGroup 'rg-prod', 'rg-staging', 'rg-dev' |
    Where-Object Status -eq 'Empty' |
    Select-Object ResourceGroup, CheckedAt |
    Export-Csv 'empty-rgs.csv' -NoTypeInformation

Pipeline patterns

PowerShell
# Where-Object short forms (PS 3+)
Get-Process | Where-Object CPU -gt 100
Get-Process | Where-Object { $_.CPU -gt 100 -and $_.Name -ne 'Idle' }
 
# ForEach-Object
1..5 | ForEach-Object { $_ * 2 }
 
# Select-Object
Get-Process | Select-Object Name, CPU, @{ Name = 'MemMB'; Expression = { [math]::Round($_.WorkingSet / 1MB, 1) } }
 
# Group-Object / Sort-Object
Get-Process | Group-Object -Property Name | Sort-Object Count -Descending | Select-Object -First 10
 
# Measure-Object
1..100 | Measure-Object -Sum -Average -Maximum -Minimum
 
# Parallel pipeline (PS 7+)
1..20 | ForEach-Object -Parallel {
    Start-Sleep -Milliseconds (Get-Random -Maximum 500)
    "Done: $_"
} -ThrottleLimit 5

Error handling

PowerShell
try {
    $result = Get-AzResourceGroup -Name 'non-existent' -ErrorAction Stop
}
catch {
    # A try can have only ONE catch-all, and it must be the last catch block.
    # Branch on the error inside it (or use typed catch blocks - see "full reference" below).
    if ($_.Exception.Message -match 'ResourceGroupNotFound|was not found') {
        Write-Warning "Resource group not found: $($_.Exception.Message)"
    } else {
        Write-Error "Unexpected error [$($_.Exception.GetType().Name)]: $_"
        throw
    }
}
finally {
    Write-Verbose 'Cleanup complete'
}
 
# Non-terminating error to terminating
Get-Item 'missing.txt' -ErrorAction Stop
 
# Capture non-terminating errors
$errs = @()
Get-ChildItem 'C:\windows\system32' -Recurse -ErrorAction SilentlyContinue -ErrorVariable errs
Write-Warning "$($errs.Count) access errors suppressed"
 
# $? and $LASTEXITCODE
git status
if (-not $?) { throw "git exited with code $LASTEXITCODE" }

try / catch / finally - full reference

Every catch block receives the current error as $_ (an ErrorRecord). Multiple catch clauses are tested top-to-bottom; the first matching type wins.

PowerShell
try {
    # Only terminating errors are caught. Convert non-terminating ones first:
    $rg = Get-AzResourceGroup -Name $Name -ErrorAction Stop
    Invoke-RestMethod -Uri 'https://api.example.com/deploy' -ErrorAction Stop
}
catch [Microsoft.Azure.Commands.ResourceManager.Cmdlets.SdkModels.Exceptions.PSResourceGroupNotFoundException] {
    # Specific .NET exception type - catch before the broader clause
    Write-Warning "RG '$Name' not found - creating it."
    New-AzResourceGroup -Name $Name -Location 'uksouth'
}
catch [System.Net.Http.HttpRequestException], [System.Net.WebException] {
    # Multiple types in a single catch (PS 3+)
    Write-Error "Network failure during deploy: $($_.Exception.Message)"
    throw   # re-throw so the caller sees the error
}
catch {
    # Catch-all - inspect the ErrorRecord
    $ex   = $_.Exception
    $type = $ex.GetType().FullName
    $line = $_.InvocationInfo.ScriptLineNumber
    Write-Error "[$type] at line $line: $ex"
    throw
}
finally {
    # Runs unconditionally - whether try succeeded, caught, or a catch re-threw.
    # Use for cleanup: closing connections, removing temp files, etc.
    # NOTE: if finally itself throws, the original exception is lost.
    Disconnect-AzAccount -ErrorAction SilentlyContinue
    Remove-Item $tmpFile -ErrorAction SilentlyContinue
}

ErrorRecord inspection

PowerShell
catch {
    $_.Exception                          # underlying .NET exception object
    $_.Exception.Message                  # human-readable message string
    $_.Exception.GetType().FullName       # fully-qualified exception class name
    $_.Exception.InnerException           # wrapped inner exception (if any)
    $_.InvocationInfo.Line                # source line that triggered the error
    $_.InvocationInfo.ScriptLineNumber    # line number
    $_.InvocationInfo.ScriptName          # script file path
    $_.ScriptStackTrace                   # full PS call stack as a string
    $_.CategoryInfo.Category              # e.g. ObjectNotFound, PermissionDenied
    $_.FullyQualifiedErrorId              # e.g. 'ResourceGroupNotFound,Microsoft.Azure...'
    $_.TargetObject                       # the object the cmdlet was operating on
}

trap vs try/catch

trap is a scope-level statement handler (PS 1.x era). try/catch is block-scoped and structured. Prefer try/catch for all new code - trap is mainly useful as a last-resort script-level safety net.

try / catchtrap
ScopeTied to a specific try blockEntire enclosing scope (function, script)
SpecificityPer-block, ordered type matchingSingle handler per exception type per scope
Flow controlRe-throw with throw; swallow by not throwingbreak exits the scope, continue resumes after the error line
NestingInner try shadows outerInner scope trap shadows outer scope
When to useStructured error handling in functions and scriptsScript-level safety nets; cleanup-on-exit guards
PowerShell
# trap - handles any terminating error in the current scope
trap [System.IO.IOException] {
    Write-Warning "IO error: $_"
    continue   # resume execution after the line that errored
}
 
trap {
    Write-Error "Unhandled terminating error: $_"
    break      # exit the current scope (propagate to caller)
}
 
# Script-level safety net combining trap + cleanup list
$script:Cleanup = [System.Collections.Generic.List[scriptblock]]::new()
 
trap {
    Write-Error "Fatal: $_"
    foreach ($action in $script:Cleanup) { & $action }
    exit 1
}
 
$script:Cleanup.Add({ Remove-Item $tmpDir -Recurse -ErrorAction SilentlyContinue })
$script:Cleanup.Add({ Disconnect-AzAccount -ErrorAction SilentlyContinue })
 
# --- try/catch does the same thing with explicit structure ---
try {
    & ./risky-operation.ps1
}
catch {
    Write-Error "Fatal: $_"
    throw
}
finally {
    Remove-Item $tmpDir -Recurse -ErrorAction SilentlyContinue
    Disconnect-AzAccount -ErrorAction SilentlyContinue
}

Key gotcha: trap only catches terminating errors. With $ErrorActionPreference = 'Continue' (the default), most cmdlet errors are non-terminating and slip past both trap and catch. Always use -ErrorAction Stop on cmdlets you want caught, or set $ErrorActionPreference = 'Stop' at the top of your script.

Switch statement

PowerShell
switch ($Environment) {
    'prod'    { $sku = 'Standard_D4s_v5'; break }
    'staging' { $sku = 'Standard_D2s_v5'; break }
    default   { $sku = 'Standard_B2ms' }
}
 
# -Regex and -Wildcard switches
switch -Regex ($message) {
    '^ERROR'   { Write-Error $_; break }
    '^WARN'    { Write-Warning $_; break }
    '^INFO'    { Write-Verbose $_ }
}
 
# Switch on type
switch ($value) {
    { $_ -is [int] }    { "Integer: $_" }
    { $_ -is [string] } { "String: $_" }
}

Regular expressions

PowerShell
# Match and capture
if ('rg-myapp-prod-uksouth' -match '^rg-(?<app>[^-]+)-(?<env>[^-]+)-(?<region>.+)$') {
    $Matches['app']    # myapp
    $Matches['env']    # prod
    $Matches['region'] # uksouth
}
 
# Replace
'hello-world_foo' -replace '[-_]', ' '
 
# Select-String (grep)
Get-Content 'deploy.log' | Select-String -Pattern 'ERROR|FATAL' -CaseSensitive
 
# Extract all matches
[regex]::Matches('ip:10.0.0.1 ip:192.168.1.1', '\d+\.\d+\.\d+\.\d+') |
    ForEach-Object { $_.Value }

Advanced Functions

Full advanced function template

PowerShell
function Invoke-MyOperation {
    [CmdletBinding(SupportsShouldProcess, DefaultParameterSetName = 'ByName')]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory, ParameterSetName = 'ByName', ValueFromPipelineByPropertyName)]
        [ValidateNotNullOrEmpty()]
        [string]$Name,
 
        [Parameter(Mandatory, ParameterSetName = 'ById')]
        [ValidatePattern('^\d+$')]
        [string]$Id,
 
        [Parameter()]
        [ValidateRange(1, 100)]
        [int]$Retries = 3,
 
        [Parameter()]
        [ValidateSet('JSON', 'CSV', 'Table')]
        [string]$OutputFormat = 'Table',
 
        [Parameter(ValueFromPipeline)]
        [object[]]$InputObject
    )
 
    begin {
        Write-Verbose "Starting $($MyInvocation.MyCommand.Name)"
        $results = [System.Collections.Generic.List[PSCustomObject]]::new()
    }
 
    process {
        foreach ($item in $InputObject) {
            if ($PSCmdlet.ShouldProcess($item, 'Process')) {
                $results.Add([PSCustomObject]@{ Item = $item; Status = 'OK' })
            }
        }
    }
 
    end {
        switch ($OutputFormat) {
            'JSON'  { $results | ConvertTo-Json -Depth 5 }
            'CSV'   { $results | Export-Csv -Path 'output.csv' -NoTypeInformation }
            default { $results | Format-Table -AutoSize }
        }
    }
}

CmdletBinding reference

PowerShell
[CmdletBinding(
    SupportsShouldProcess,            # adds -WhatIf and -Confirm; required to call $PSCmdlet.ShouldProcess()
    ConfirmImpact = 'High',           # None | Low | Medium | High - auto-prompts when -Confirm not set
    DefaultParameterSetName = 'ByName',
    SupportsPaging,                   # adds -First, -Skip, -IncludeTotalCount
    PositionalBinding = $false        # disable auto-positional parameter binding
)]

Parameter attributes - quick reference

PowerShell
param (
    # Mandatory, positional, pipeline-aware
    [Parameter(
        Mandatory,
        Position = 0,
        ValueFromPipeline,                  # accepts whole objects from pipeline
        ValueFromPipelineByPropertyName,    # matches property name to param name
        HelpMessage = 'The resource name'
    )]
    [string]$Name,
 
    # Validation attributes (stackable)
    [ValidateNotNullOrEmpty()]
    [ValidateLength(1, 64)]
    [ValidatePattern('^[a-z][a-z0-9-]{0,62}$')]
    [string]$Slug,
 
    [ValidateRange(1, 65535)]
    [int]$Port = 443,
 
    [ValidateSet('dev', 'staging', 'prod')]
    [string]$Environment = 'dev',
 
    [ValidateCount(1, 10)]              # min/max number of elements in an array
    [string[]]$Regions,
 
    [ValidateScript({
        Test-Path $_ -PathType Leaf    # scriptblock must return $true or throw
    })]
    [string]$ConfigFile,
 
    # Parameter sets
    [Parameter(Mandatory, ParameterSetName = 'ById')]
    [ValidatePattern('^\d+$')]
    [string]$Id,
 
    [Parameter(Mandatory, ParameterSetName = 'ByName')]
    [string]$DisplayName,
 
    # Common extras
    [switch]$Force,
    [switch]$PassThru    # return the processed object when normally silent
)

begin / process / end - pipeline processing

PowerShell
function Measure-DeployTime {
    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param (
        [Parameter(Mandatory, ValueFromPipeline)]
        [string]$ResourceGroup
    )
 
    begin {
        # Runs once before any pipeline input.
        # Initialise accumulators, open connections, load modules.
        $results  = [System.Collections.Generic.List[PSCustomObject]]::new()
        $sw       = [System.Diagnostics.Stopwatch]::StartNew()
        Write-Verbose "Measure-DeployTime: starting batch"
    }
 
    process {
        # Runs once per pipeline object (or per element in an array arg).
        # $ResourceGroup is bound to each incoming value.
        $itemSw = [System.Diagnostics.Stopwatch]::StartNew()
        try {
            $rg = Get-AzResourceGroup -Name $ResourceGroup -ErrorAction Stop
            $results.Add([PSCustomObject]@{
                Name      = $ResourceGroup
                Location  = $rg.Location
                Status    = 'OK'
                ElapsedMs = $itemSw.ElapsedMilliseconds
            })
        }
        catch {
            Write-Warning "Failed for '$ResourceGroup': $_"
            $results.Add([PSCustomObject]@{ Name = $ResourceGroup; Status = 'Error'; ElapsedMs = $itemSw.ElapsedMilliseconds })
        }
    }
 
    end {
        # Runs once after all pipeline input is processed.
        # Emit collected results, close connections, summarise.
        $sw.Stop()
        Write-Verbose "Batch complete in $($sw.ElapsedMilliseconds)ms"
        $results   # emit to pipeline
    }
}
 
# Pipeline usage - process runs once per item
'rg-dev', 'rg-staging', 'rg-prod' | Measure-DeployTime -Verbose

ShouldProcess / WhatIf / Confirm

PowerShell
function Remove-StaleResources {
    [CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
    param (
        [Parameter(Mandatory)][string]$ResourceGroupName,
        [int]$OlderThanDays = 30
    )
 
    $cutoff    = (Get-Date).AddDays(-$OlderThanDays)
    $resources = Get-AzResource -ResourceGroupName $ResourceGroupName |
                 Where-Object { $_.ChangedTime -lt $cutoff }
 
    foreach ($res in $resources) {
        # ShouldProcess returns $true normally, $false under -WhatIf, prompts under -Confirm
        if ($PSCmdlet.ShouldProcess($res.Name, "Remove resource")) {
            Remove-AzResource -ResourceId $res.Id -Force
            Write-Verbose "Removed $($res.Name)"
        }
    }
}
 
Remove-StaleResources -ResourceGroupName 'rg-old' -WhatIf       # shows what would happen
Remove-StaleResources -ResourceGroupName 'rg-old' -Confirm       # prompts for each
Remove-StaleResources -ResourceGroupName 'rg-old' -Force         # (with [switch]$Force) skips confirmation

Retry helper

PowerShell
function Invoke-WithRetry {
    param (
        [Parameter(Mandatory)][scriptblock]$ScriptBlock,
        [int]$MaxRetries   = 3,
        [int]$DelaySeconds = 5
    )
    $attempt = 0
    while ($true) {
        try {
            return & $ScriptBlock
        }
        catch {
            $attempt++
            if ($attempt -ge $MaxRetries) { throw }
            Write-Warning "Attempt $attempt failed: $_. Retrying in ${DelaySeconds}s..."
            Start-Sleep -Seconds $DelaySeconds
        }
    }
}
 
# Usage
Invoke-WithRetry -MaxRetries 5 -DelaySeconds 10 -ScriptBlock {
    Invoke-RestMethod -Uri 'https://api.example.com/data' -TimeoutSec 30
}

File & I/O

CSV and JSON

PowerShell
# CSV round-trip
$data = Import-Csv -Path 'input.csv' -Delimiter ','
$data | Where-Object { $_.Status -eq 'Active' } |
    Select-Object Name, Email |
    Export-Csv -Path 'active.csv' -NoTypeInformation -Encoding UTF8
 
# JSON
$json = Get-Content 'config.json' -Raw | ConvertFrom-Json
$json.settings.timeout = 60
$json | ConvertTo-Json -Depth 10 | Set-Content 'config.json' -Encoding UTF8
 
# Pretty-print arbitrary JSON string
'{"a":1,"b":2}' | ConvertFrom-Json | ConvertTo-Json

Path manipulation

PowerShell
# Join paths safely (handles separators)
$config = Join-Path $PSScriptRoot 'config' 'app.json'
 
# Resolve (must exist)
Resolve-Path '~/Documents'
 
# Test existence
Test-Path $config -PathType Leaf
Test-Path (Split-Path $config) -PathType Container
 
# Temp file
$tmp = [System.IO.Path]::GetTempFileName()
try {
    # use $tmp
} finally {
    Remove-Item $tmp -ErrorAction SilentlyContinue
}
 
# Enumerate recursively
Get-ChildItem -Path '.' -Recurse -Filter '*.tf' -File |
    Where-Object { $_.Length -gt 10KB }

Compression

PowerShell
# Create zip
Compress-Archive -Path 'dist/*' -DestinationPath 'release.zip' -Force
 
# Extract zip
Expand-Archive -Path 'release.zip' -DestinationPath './extracted' -Force
 
# .NET for streaming (large files)
Add-Type -AssemblyName System.IO.Compression.FileSystem
[System.IO.Compression.ZipFile]::CreateFromDirectory('./dist', 'release.zip')

REST APIs

Invoke-RestMethod patterns

PowerShell
# GET with headers and query params
$response = Invoke-RestMethod `
    -Uri    'https://api.github.com/repos/owner/repo/releases' `
    -Method GET `
    -Headers @{
        Authorization = "Bearer $env:GITHUB_TOKEN"
        Accept        = 'application/vnd.github+json'
    } `
    -TimeoutSec 30
 
# POST JSON body
$body = @{ name = 'new-branch'; sha = 'abc123' } | ConvertTo-Json
Invoke-RestMethod `
    -Uri         'https://api.github.com/repos/owner/repo/git/refs' `
    -Method      POST `
    -Headers     @{ Authorization = "Bearer $env:GITHUB_TOKEN" } `
    -Body        $body `
    -ContentType 'application/json'
 
# Handle pagination (Link header)
function Get-AllPages {
    param ([string]$Url, [hashtable]$Headers)
    do {
        $response = Invoke-WebRequest -Uri $Url -Headers $Headers
        $Url      = $null
        if ($response.Headers['Link'] -match '<([^>]+)>;\s*rel="next"') {
            $Url = $Matches[1]
        }
        $response.Content | ConvertFrom-Json | Select-Object -ExpandProperty items
    } while ($Url)
}

Invoke-AzRestMethod (Azure Resource Manager)

PowerShell
# Direct ARM call - useful for preview APIs or operations without cmdlets
function Invoke-ArmApi {
    param (
        [string]$Method = 'GET',
        [Parameter(Mandatory)][string]$ResourceId,
        [string]$ApiVersion = '2023-07-01',
        [object]$Body
    )
    $uri = "https://management.azure.com${ResourceId}?api-version=${ApiVersion}"
    $params = @{ Method = $Method; Uri = $uri }
    if ($Body) { $params['Payload'] = ($Body | ConvertTo-Json -Depth 10) }
    (Invoke-AzRestMethod @params).Content | ConvertFrom-Json
}
 
# Get resource locks
Invoke-ArmApi -ResourceId "/subscriptions/$subId/resourceGroups/$rgName/providers/Microsoft.Authorization/locks" -ApiVersion '2016-09-01'

Jobs & Parallelism

Start-ThreadJob (parallel, in-process)

PowerShell
# Requires: Install-Module ThreadJob (built-in PS 7+)
$jobs = 'rg-dev', 'rg-staging', 'rg-prod' | ForEach-Object {
    $rg = $_
    Start-ThreadJob -ScriptBlock {
        param($Name)
        Get-AzResourceGroup -Name $Name | Select-Object ResourceGroupName, Location, ProvisioningState
    } -ArgumentList $rg
}
 
$results = $jobs | Wait-Job | Receive-Job
$jobs    | Remove-Job
$results | Format-Table

Background jobs

PowerShell
$job = Start-Job -ScriptBlock {
    param($Uri)
    Invoke-RestMethod $Uri
} -ArgumentList 'https://api.example.com/slow-endpoint'
 
# Poll
while ($job.State -eq 'Running') {
    Write-Host "Waiting..." -NoNewline
    Start-Sleep -Seconds 2
}
 
$result = Receive-Job $job -ErrorAction Stop
Remove-Job $job

ForEach-Object -Parallel

PowerShell
$subscriptions = Get-AzSubscription
 
$report = $subscriptions | ForEach-Object -Parallel {
    Import-Module Az.Resources -ErrorAction Stop
    Set-AzContext -SubscriptionId $_.Id | Out-Null
    $rgs = Get-AzResourceGroup
    [PSCustomObject]@{
        Subscription = $_.Name
        RGCount      = $rgs.Count
    }
} -ThrottleLimit 10 -TimeoutSeconds 120
 
$report | Sort-Object RGCount -Descending | Format-Table

Azure Authentication

Service principal (client secret) ⚠️ Legacy - prefer managed identity or OIDC federation

PowerShell
function Connect-AzSP {
    param (
        [Parameter(Mandatory)][string]$ApplicationId,
        [Parameter(Mandatory)][string]$TenantId,
        [Parameter(Mandatory)][string]$ClientSecret,
        [string]$SubscriptionId
    )
    $cred = [System.Management.Automation.PSCredential]::new(
        $ApplicationId,
        ($ClientSecret | ConvertTo-SecureString -AsPlainText -Force)
    )
    Connect-AzAccount -ServicePrincipal -Credential $cred -Tenant $TenantId -ErrorAction Stop
    if ($SubscriptionId) { Set-AzContext -SubscriptionId $SubscriptionId | Out-Null }
}

Managed identity (IMDS / workload identity) ✅ Current preferred auth for Azure-hosted automation (as of 2026)

PowerShell
# System-assigned MI (no credentials needed)
Connect-AzAccount -Identity
 
# User-assigned MI
Connect-AzAccount -Identity -AccountId '<client-id-of-uami>'

Federated / OIDC (GitHub Actions) ✅ Current preferred CI/CD auth pattern (as of 2026)

PowerShell
# In a GitHub Actions workflow, ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN are set
$token = (Invoke-RestMethod `
    -Uri     "$env:ACTIONS_ID_TOKEN_REQUEST_URL&audience=api://AzureADTokenExchange" `
    -Headers @{ Authorization = "Bearer $env:ACTIONS_ID_TOKEN_REQUEST_TOKEN" }
).value
 
Connect-AzAccount `
    -ApplicationId $env:AZURE_CLIENT_ID `
    -TenantId      $env:AZURE_TENANT_ID `
    -FederatedToken $token

Switch subscription context

PowerShell
# List all subscriptions
Get-AzSubscription | Format-Table Name, Id, State
 
# Set by name or ID
Set-AzContext -SubscriptionName 'Production'
Set-AzContext -SubscriptionId  '00000000-0000-0000-0000-000000000000'
 
# Save and restore context
$ctx = Get-AzContext
# ... do stuff on another subscription ...
Set-AzContext -Context $ctx

See also: Azure - Auth & Context for Az CLI equivalents and managed identity patterns. Terraform - Provider & Authentication for using SPN credentials with the AzureRM provider.


Azure Resources

Resource groups and tags

PowerShell
# Create RG
New-AzResourceGroup -Name 'rg-myapp-prod-uksouth' -Location 'uksouth' -Tag @{
    Environment = 'prod'
    Owner       = 'platform-team'
    CostCentre  = 'CC-1234'
}
 
# Update tags (merge, don't overwrite)
$rg   = Get-AzResourceGroup -Name 'rg-myapp-prod-uksouth'
$tags = $rg.Tags + @{ NewTag = 'value' }
Set-AzResourceGroup -Name $rg.ResourceGroupName -Tag $tags
 
# Tag all resources in an RG
Get-AzResource -ResourceGroupName 'rg-myapp-prod-uksouth' | ForEach-Object {
    Update-AzTag -ResourceId $_.Id -Tag @{ Environment = 'prod' } -Operation Merge
}
 
# Find resources missing a tag
Get-AzResource | Where-Object { -not $_.Tags.ContainsKey('Environment') } |
    Select-Object Name, ResourceType, ResourceGroupName

Key Vault secrets

PowerShell
# Set secret
$secretValue = ConvertTo-SecureString 'my-super-secret' -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName 'kv-myapp-prod' -Name 'db-password' -SecretValue $secretValue
 
# Get secret (as plain text - only in memory)
$secret = (Get-AzKeyVaultSecret -VaultName 'kv-myapp-prod' -Name 'db-password' -AsPlainText)
 
# List secrets
Get-AzKeyVaultSecret -VaultName 'kv-myapp-prod' | Select-Object Name, Enabled, Expires
 
# Rotate - create new version
$newVal = ConvertTo-SecureString (New-Guid).Guid -AsPlainText -Force
Set-AzKeyVaultSecret -VaultName 'kv-myapp-prod' -Name 'api-key' -SecretValue $newVal
 
# Grant access (RBAC model)
New-AzRoleAssignment `
    -SignInName      'user@example.com' `
    -RoleDefinitionName 'Key Vault Secrets User' `
    -Scope "/subscriptions/$subId/resourceGroups/rg-myapp-prod-uksouth/providers/Microsoft.KeyVault/vaults/kv-myapp-prod"

RBAC assignments

PowerShell
# Assign role to service principal
$sp = Get-AzADServicePrincipal -DisplayName 'myapp-deploy-sp'
New-AzRoleAssignment `
    -ObjectId           $sp.Id `
    -RoleDefinitionName 'Contributor' `
    -Scope              "/subscriptions/$subId/resourceGroups/rg-myapp-prod-uksouth"
 
# List assignments on a scope
Get-AzRoleAssignment -Scope "/subscriptions/$subId" |
    Select-Object DisplayName, RoleDefinitionName, SignInName, ObjectType |
    Sort-Object RoleDefinitionName
 
# Remove assignment
Remove-AzRoleAssignment `
    -ObjectId           $sp.Id `
    -RoleDefinitionName 'Contributor' `
    -Scope              "/subscriptions/$subId/resourceGroups/rg-myapp-prod-uksouth"

Register resource provider (with wait)

PowerShell
function Register-AzProviderWithWait {
    param ([Parameter(Mandatory)][string]$ProviderNamespace)
    # Get-AzResourceProvider returns one object per resource type - use [0] for a scalar state
    $state = (Get-AzResourceProvider -ProviderNamespace $ProviderNamespace)[0].RegistrationState
    if ($state -eq 'Registered') {
        Write-Verbose "$ProviderNamespace already registered."
        return
    }
    Write-Host "Registering $ProviderNamespace..." -ForegroundColor Yellow
    Register-AzResourceProvider -ProviderNamespace $ProviderNamespace | Out-Null
    do {
        Start-Sleep -Seconds 10
        $state = (Get-AzResourceProvider -ProviderNamespace $ProviderNamespace)[0].RegistrationState
        Write-Host "  State: $state"
    } while ($state -ne 'Registered')
    Write-Host "$ProviderNamespace registered." -ForegroundColor Green
}
 
Register-AzProviderWithWait 'Microsoft.ContainerService'
Register-AzProviderWithWait 'Microsoft.KeyVault'

Storage operations

PowerShell
# Get storage account context
$storageCtx = (Get-AzStorageAccount -ResourceGroupName 'rg-data' -Name 'stdataprod').Context
 
# Upload file
Set-AzStorageBlobContent `
    -File      'local-file.parquet' `
    -Container 'raw' `
    -Blob      "data/$(Get-Date -Format 'yyyy/MM/dd')/file.parquet" `
    -Context   $storageCtx `
    -Force
 
# List blobs with prefix
Get-AzStorageBlob -Container 'raw' -Prefix 'data/2025/' -Context $storageCtx |
    Sort-Object LastModified -Descending |
    Select-Object Name, Length, LastModified
 
# Generate SAS URL (1-hour read access)
New-AzStorageBlobSASToken `
    -Container  'raw' `
    -Blob       'data/file.parquet' `
    -Permission 'r' `
    -ExpiryTime (Get-Date).AddHours(1) `
    -FullUri `
    -Context    $storageCtx

App Registrations & Service Principals

PowerShell
# Create app registration
$app = New-AzADApplication -DisplayName 'myapp-ci' -SignInAudience 'AzureADMyOrg'
 
# Create SP linked to app
$sp = New-AzADServicePrincipal -ApplicationId $app.AppId
 
# Add client secret (auto-generated)
$cred = New-AzADAppCredential -ApplicationId $app.AppId -EndDate (Get-Date).AddYears(1)
# $cred.SecretText contains the secret - capture immediately, not retrievable again
 
# Add federated credential (for GitHub Actions OIDC)
$fedCred = @{
    Audiences = @('api://AzureADTokenExchange')
    Issuer    = 'https://token.actions.githubusercontent.com'
    Name      = 'github-actions-main'
    Subject   = 'repo:myorg/myrepo:ref:refs/heads/main'
}
New-AzADAppFederatedCredential -ApplicationId $app.Id @fedCred
 
# Clean up expired secrets
Get-AzADAppCredential -ApplicationId $app.AppId |
    Where-Object { $_.EndDateTime -lt (Get-Date) } |
    ForEach-Object { Remove-AzADAppCredential -ApplicationId $app.AppId -KeyId $_.KeyId }

Microsoft Graph

PowerShell
# Connect (delegated)
Connect-MgGraph -Scopes 'User.Read.All', 'Group.ReadWrite.All', 'Directory.Read.All'
 
# Connect (app-only with client secret)
$clientSecret = ConvertTo-SecureString $env:GRAPH_CLIENT_SECRET -AsPlainText -Force
$cred         = [System.Management.Automation.PSCredential]::new($env:AZURE_CLIENT_ID, $clientSecret)
Connect-MgGraph -TenantId $env:AZURE_TENANT_ID -ClientSecretCredential $cred
 
# Users
Get-MgUser -Filter "Department eq 'Engineering'" -Select 'DisplayName,Mail,JobTitle' -All |
    Export-Csv 'engineers.csv' -NoTypeInformation
 
# Find disabled accounts that are members of groups
Get-MgUser -Filter "AccountEnabled eq false" -All | ForEach-Object {
    $groups = Get-MgUserMemberOf -UserId $_.Id
    if ($groups) {
        [PSCustomObject]@{ User = $_.DisplayName; Groups = ($groups.AdditionalProperties['displayName'] -join ', ') }
    }
}
 
# Groups
$group = Get-MgGroup -Filter "DisplayName eq 'Platform Engineers'"
Get-MgGroupMember -GroupId $group.Id -All | Select-Object Id
 
# Add member
New-MgGroupMember -GroupId $group.Id -DirectoryObjectId $userId
 
# Remove member
Remove-MgGroupMemberByRef -GroupId $group.Id -DirectoryObjectId $userId
 
# App registrations via Graph
Get-MgApplication -Filter "DisplayName eq 'myapp-ci'" |
    Select-Object DisplayName, AppId, Id

Azure Policy

PowerShell
# List non-compliant resources
Get-AzPolicyState -Filter "ComplianceState eq 'NonCompliant'" |
    Select-Object ResourceId, PolicyDefinitionName, ComplianceState |
    Sort-Object PolicyDefinitionName |
    Format-Table -AutoSize
 
# Assign built-in policy
$definition = Get-AzPolicyDefinition -BuiltIn | Where-Object { $_.DisplayName -match 'Require a tag' }
New-AzPolicyAssignment `
    -Name               'require-env-tag' `
    -DisplayName        'Require Environment tag' `
    -Scope              "/subscriptions/$subId" `
    -PolicyDefinition   $definition `
    -PolicyParameterObject @{ tagName = @{ value = 'Environment' } }
 
# Trigger compliance scan
Start-AzPolicyComplianceScan -ResourceGroupName 'rg-myapp-prod-uksouth' -AsJob

Progress & Output Formatting

Progress bar

PowerShell
$items = Get-AzResourceGroup
$total = $items.Count
$i     = 0
 
foreach ($rg in $items) {
    $i++
    Write-Progress `
        -Activity        'Auditing resource groups' `
        -Status          "$i of $total - $($rg.ResourceGroupName)" `
        -PercentComplete (($i / $total) * 100)
 
    # ... do work ...
}
Write-Progress -Activity 'Auditing resource groups' -Completed

Output formatting

PowerShell
# Table
$data | Format-Table -AutoSize -Wrap
 
# Custom columns
$data | Format-Table `
    @{ Label = 'Name'; Expression = { $_.ResourceGroupName }; Width = 30 },
    @{ Label = 'Region'; Expression = { $_.Location } },
    @{ Label = 'Resources'; Expression = { (Get-AzResource -ResourceGroupName $_.ResourceGroupName).Count } }
 
# List (vertical key-value)
$data | Select-Object -First 1 | Format-List *
 
# Out-GridView (Windows / interactive only)
$data | Out-GridView -Title 'Resource Groups' -PassThru

CI/CD & Pipeline Integration

GitHub Actions integration

PowerShell
# Set output for downstream steps
function Set-GitHubOutput {
    param ([string]$Name, [string]$Value)
    "$Name=$Value" | Add-Content -Path $env:GITHUB_OUTPUT -Encoding UTF8
}
 
# Set environment variable for downstream steps
function Set-GitHubEnv {
    param ([string]$Name, [string]$Value)
    "$Name=$Value" | Add-Content -Path $env:GITHUB_ENV -Encoding UTF8
}
 
# Group log output
function Write-GitHubGroup {
    param ([string]$Title, [scriptblock]$Body)
    Write-Host "::group::$Title"
    & $Body
    Write-Host '::endgroup::'
}
 
# Mask secret from logs
function Hide-GitHubSecret {
    param ([string]$Value)
    Write-Host "::add-mask::$Value"
}
 
# Usage in a workflow step
Set-GitHubOutput -Name 'image-tag' -Value "v$(Get-Date -Format 'yyyyMMddHHmmss')"
Write-GitHubGroup 'Deploying infrastructure' {
    terraform apply -auto-approve
}

Azure DevOps integration

PowerShell
# Set variable for downstream tasks
function Set-AzDOVariable {
    param ([string]$Name, [string]$Value, [switch]$IsSecret, [switch]$IsOutput)
    $flags  = if ($IsSecret) { ';issecret=true' } else { '' }
    $flags += if ($IsOutput) { ';isoutput=true' } else { '' }
    Write-Host "##vso[task.setvariable variable=$Name$flags]$Value"
}
 
# Set task result
function Set-AzDOResult {
    param ([ValidateSet('Succeeded', 'SucceededWithIssues', 'Failed')][string]$Result, [string]$Message)
    Write-Host "##vso[task.complete result=$Result;]$Message"
}
 
# Detect pipeline vs local
$inPipeline = ($env:TF_BUILD -eq 'True') -or ($env:GITHUB_ACTIONS -eq 'true')
if ($inPipeline) {
    Set-AzDOVariable -Name 'DeployVersion' -Value '1.2.3' -IsOutput
}

Exit codes and error propagation

PowerShell
$ErrorActionPreference = 'Stop'
 
# Native commands - check $LASTEXITCODE
function Invoke-Native {
    param ([scriptblock]$Command)
    & $Command
    if ($LASTEXITCODE -ne 0) {
        throw "Command failed with exit code $LASTEXITCODE"
    }
}
 
Invoke-Native { terraform init }
Invoke-Native { terraform plan -out=tfplan }
Invoke-Native { terraform apply tfplan }

Pester Testing

🛠️ Deeper reference - covers test structure, configuration, mocking patterns, and tag-filtered runs. Assumes Pester 5.6+. For quick test execution, jump to “Pester configuration and running”.

Basic test structure

PowerShell
# tests/Deploy.Tests.ps1
BeforeAll {
    . "$PSScriptRoot/../src/Deploy.ps1"
 
    # Mock Azure commands
    Mock Connect-AzAccount {}
    Mock Get-AzResourceGroup { return [PSCustomObject]@{ ResourceGroupName = 'rg-test'; Location = 'uksouth' } }
    Mock New-AzResourceGroup {}
}
 
Describe 'Deploy-Infrastructure' {
    Context 'When resource group does not exist' {
        BeforeEach {
            Mock Get-AzResourceGroup { return $null }
        }
 
        It 'creates the resource group' {
            Deploy-Infrastructure -Name 'rg-test' -Location 'uksouth'
            Should -Invoke New-AzResourceGroup -Times 1 -Exactly -ParameterFilter {
                $Name -eq 'rg-test' -and $Location -eq 'uksouth'
            }
        }
    }
 
    Context 'When resource group already exists' {
        It 'does not create a duplicate' {
            Deploy-Infrastructure -Name 'rg-test' -Location 'uksouth'
            Should -Invoke New-AzResourceGroup -Times 0
        }
    }
 
    Context 'Parameter validation' {
        It 'throws on empty name' {
            { Deploy-Infrastructure -Name '' -Location 'uksouth' } | Should -Throw
        }
 
        It 'throws on invalid location' {
            { Deploy-Infrastructure -Name 'rg-test' -Location 'mars-central' } | Should -Throw -ExceptionType ([System.Management.Automation.ParameterBindingException])
        }
    }
}

Pester configuration and running

PowerShell
# Run all tests
Invoke-Pester -Path './tests'
 
# With coverage
$config = New-PesterConfiguration
$config.Run.Path         = './tests'
$config.CodeCoverage.Enabled  = $true
$config.CodeCoverage.Path     = './src/*.ps1'
$config.Output.Verbosity      = 'Detailed'
$config.TestResult.Enabled    = $true
$config.TestResult.OutputPath = 'test-results.xml'
Invoke-Pester -Configuration $config
 
# Tag-filtered run
$config.Filter.Tag = @('Unit')
Invoke-Pester -Configuration $config

Mocking patterns

PowerShell
Describe 'Invoke-RestMethod mocking' {
    BeforeAll {
        Mock Invoke-RestMethod {
            return [PSCustomObject]@{ status = 'ok'; id = '12345' }
        } -ParameterFilter { $Uri -match 'api\.example\.com' }
 
        Mock Invoke-RestMethod {
            throw 'Network error'
        } -ParameterFilter { $Uri -match 'bad\.host' }
    }
 
    It 'returns the mocked response' {
        $result = Invoke-RestMethod -Uri 'https://api.example.com/data'
        $result.id | Should -Be '12345'
    }
 
    It 'retries on failure' {
        { Invoke-WithRetry -MaxRetries 2 -ScriptBlock { Invoke-RestMethod 'https://bad.host' } } |
            Should -Throw 'Network error'
        Should -Invoke Invoke-RestMethod -Times 2 -ParameterFilter { $Uri -match 'bad\.host' }
    }
}

Microsoft Sentinel

Export watchlist to CSV

PowerShell
function Export-SentinelWatchlist {
    [CmdletBinding()]
    param (
        [string]$SubscriptionId    = (Get-AzContext).Subscription.Id,
        [Parameter(Mandatory)][string]$ResourceGroupName,
        [Parameter(Mandatory)][string]$WorkspaceName,
        [Parameter(Mandatory)][string]$WatchlistName,
        [string]$OutputPath = 'watchlist.csv'
    )
    $uri = "https://management.azure.com/subscriptions/$SubscriptionId" +
           "/resourceGroups/$ResourceGroupName" +
           "/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName" +
           "/providers/Microsoft.SecurityInsights/watchlists/$WatchlistName" +
           "/watchlistItems?api-version=2023-02-01"
 
    $items = @()
    do {
        $response = (Invoke-AzRestMethod -Method GET -Uri $uri).Content | ConvertFrom-Json
        $items   += $response.value.properties.itemsKeyValue
        $uri      = $response.nextLink
    } while ($uri)
 
    $items | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
    Write-Host "Exported $($items.Count) items to $OutputPath" -ForegroundColor Green
}

Bulk update watchlist items

PowerShell
function Add-SentinelWatchlistItem {
    param (
        [string]$SubscriptionId = (Get-AzContext).Subscription.Id,
        [Parameter(Mandatory)][string]$ResourceGroupName,
        [Parameter(Mandatory)][string]$WorkspaceName,
        [Parameter(Mandatory)][string]$WatchlistName,
        [Parameter(Mandatory)][hashtable]$ItemProperties
    )
    $uri  = "https://management.azure.com/subscriptions/$SubscriptionId" +
            "/resourceGroups/$ResourceGroupName" +
            "/providers/Microsoft.OperationalInsights/workspaces/$WorkspaceName" +
            "/providers/Microsoft.SecurityInsights/watchlists/$WatchlistName" +
            "/watchlistItems/$(New-Guid)?api-version=2023-02-01"
    $body = @{ properties = @{ itemsKeyValue = $ItemProperties } } | ConvertTo-Json -Depth 5
    (Invoke-AzRestMethod -Method PUT -Uri $uri -Payload $body).Content | ConvertFrom-Json
}
 
# Bulk import from CSV
Import-Csv 'new-indicators.csv' | ForEach-Object {
    Add-SentinelWatchlistItem `
        -ResourceGroupName 'rg-sentinel' `
        -WorkspaceName     'law-sentinel-prod' `
        -WatchlistName     'BlockedIPs' `
        -ItemProperties    @{ IPAddress = $_.IPAddress; Reason = $_.Reason; AddedDate = (Get-Date -Format 'o') }
}

See also: KQL - the query language that runs inside Sentinel. Covers scheduled rule structure, threat hunting across Defender tables, and watchlist lookups in KQL.


Module Management

Install and update modules

PowerShell
# Install multiple modules
# Install-PSResource uses -Version (a specific version or NuGet range), not -RequiredVersion.
$modules = @(
    @{ Name = 'Az';                     Version = '12.0.0' }
    @{ Name = 'Microsoft.Graph';        Version = '[2,3)' }
    @{ Name = 'Pester';                 Version = '5.6.0' }
    @{ Name = 'PSScriptAnalyzer' }
)
foreach ($mod in $modules) {
    Install-PSResource @mod -Scope CurrentUser -TrustRepository -Repository PSGallery
}
 
# Update all installed modules
Get-InstalledPSResource | Where-Object { -not $_.Prerelease } | ForEach-Object {
    Update-PSResource -Name $_.Name -Scope CurrentUser -TrustRepository -ErrorAction SilentlyContinue
}
 
# Check for outdated modules
Get-InstalledPSResource | ForEach-Object {
    $latest = Find-PSResource -Name $_.Name -Repository PSGallery -ErrorAction SilentlyContinue
    if ($latest -and $latest.Version -gt $_.Version) {
        [PSCustomObject]@{ Module = $_.Name; Installed = $_.Version; Latest = $latest.Version }
    }
} | Format-Table
 
# PSScriptAnalyzer
Invoke-ScriptAnalyzer -Path './src' -Recurse -Settings PSGallery |
    Where-Object { $_.Severity -ne 'Information' } |
    Format-Table ScriptName, Line, Severity, RuleName, Message

Module boilerplate

PowerShell
# MyModule/MyModule.psm1
$Public  = @(Get-ChildItem -Path "$PSScriptRoot/Public/*.ps1"  -ErrorAction SilentlyContinue)
$Private = @(Get-ChildItem -Path "$PSScriptRoot/Private/*.ps1" -ErrorAction SilentlyContinue)
 
foreach ($file in ($Public + $Private)) {
    try { . $file.FullName }
    catch { Write-Error "Failed to import $($file.FullName): $_" }
}
 
Export-ModuleMember -Function $Public.BaseName
 
# MyModule/MyModule.psd1 (manifest)
# New-ModuleManifest -Path ./MyModule.psd1 -RootModule MyModule.psm1 -FunctionsToExport @('Get-Thing','Set-Thing')

Profile & Shell Setup 🏠 Personal workflow configuration

$PROFILE locations

PowerShell
# Show all profile paths
$PROFILE | Select-Object *
 
# Create profile directory and file if missing
if (-not (Test-Path $PROFILE)) {
    New-Item -Path $PROFILE -ItemType File -Force | Out-Null
}
 
# Reload profile
. $PROFILE

Useful profile additions

PowerShell
# In $PROFILE:
 
Set-StrictMode -Version Latest
 
# Aliases
Set-Alias -Name 'k'   -Value 'kubectl'
Set-Alias -Name 'tf'  -Value 'terraform'
Set-Alias -Name 'g'   -Value 'git'
 
# Auto-complete for common tools
if (Get-Command kubectl -ErrorAction SilentlyContinue) {
    kubectl completion powershell | Out-String | Invoke-Expression
}
if (Get-Command terraform -ErrorAction SilentlyContinue) {
    terraform -install-autocomplete 2>$null
}
if (Get-Command az -ErrorAction SilentlyContinue) {
    Register-ArgumentCompleter -Native -CommandName az -ScriptBlock {
        param($commandName, $wordToComplete, $cursorPosition)
        $completion_file = New-TemporaryFile
        $env:ARGCOMPLETE_USE_TEMPFILES = 1
        $env:_ARGCOMPLETE_STDOUT_FILENAME = $completion_file
        $env:COMP_LINE = $wordToComplete
        $env:COMP_POINT = $cursorPosition
        $env:_ARGCOMPLETE = 1
        $env:_ARGCOMPLETE_SUPPRESS_SPACE = 0
        $env:_ARGCOMPLETE_IFS = "`n"
        $env:_ARGCOMPLETE_SHELL = 'powershell'
        az 2>&1 | Out-Null
        Get-Content $completion_file | ForEach-Object { [System.Management.Automation.CompletionResult]::new($_, $_, 'ParameterValue', $_) }
        Remove-Item $completion_file, Env:\_ARGCOMPLETE_STDOUT_FILENAME, Env:\ARGCOMPLETE_USE_TEMPFILES
    }
}
 
# Starship prompt
if (Get-Command starship -ErrorAction SilentlyContinue) {
    Invoke-Expression (&starship init powershell)
}
 
# Set Az context from env if set
if ($env:AZURE_SUBSCRIPTION_ID) {
    Set-AzContext -SubscriptionId $env:AZURE_SUBSCRIPTION_ID -ErrorAction SilentlyContinue | Out-Null
}

Starship config (Windows/cross-platform)

PowerShell
$starshipConfig = @'
command_timeout = 10000
"$schema" = "https://starship.rs/config-schema.json"
 
add_newline = true
 
[azure]
disabled = false
format   = 'on [$symbol($subscription)]($style) '
symbol   = '☁️ '
style    = 'blue bold'
 
[kubernetes]
disabled = false
format   = 'on [⛵ $context\($namespace\)]($style) '
style    = 'cyan bold'
 
[terraform]
disabled = false
format   = 'via [$symbol$workspace]($style) '
 
[character]
success_symbol = '[➜](bold green)'
error_symbol   = '[✗](bold red)'
 
[package]
disabled = true
'@
 
$starshipConfig | Set-Content -Path "$env:USERPROFILE\.config\starship.toml" -Encoding UTF8
# Linux/macOS: ~/.config/starship.toml

Useful Global Patterns

Confirm before destructive action

PowerShell
function Confirm-Action {
    param ([string]$Message, [switch]$Force)
    if ($Force) { return $true }
    $response = Read-Host "$Message [y/N]"
    return $response -match '^[Yy]$'
}
 
if (Confirm-Action 'Delete all resources in rg-old-prod?') {
    Remove-AzResourceGroup -Name 'rg-old-prod' -Force
}

Environment variable helper

PowerShell
function Get-RequiredEnv {
    param ([Parameter(Mandatory, ValueFromRemainingArguments)][string[]]$Names)
    $missing = $Names | Where-Object { -not (Get-Item "Env:\$_" -ErrorAction SilentlyContinue) }
    if ($missing) { throw "Missing required environment variables: $($missing -join ', ')" }
    $Names | ForEach-Object { [Environment]::GetEnvironmentVariable($_) }
}
 
$clientId, $secret, $tenantId = Get-RequiredEnv AZURE_CLIENT_ID AZURE_CLIENT_SECRET AZURE_TENANT_ID

Script execution time

PowerShell
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
 
# ... your script logic ...
 
$stopwatch.Stop()
Write-Host "Completed in $($stopwatch.Elapsed.ToString('mm\:ss\.ff'))" -ForegroundColor Cyan

Bulk parallel ARM deployments

PowerShell
$templates = Get-ChildItem './templates' -Filter '*.bicep'
 
$jobs = $templates | ForEach-Object {
    $file = $_
    Start-ThreadJob -ScriptBlock {
        param($Template, $RG)
        az deployment group create `
            --resource-group $RG `
            --template-file  $Template `
            --output         json | ConvertFrom-Json
    } -ArgumentList $file.FullName, 'rg-myapp-prod-uksouth'
}
 
$results = $jobs | Wait-Job | Receive-Job
$jobs    | Remove-Job
$results | Format-Table name, properties

Anti-patterns

  • 🚨 Write-Host in modules or automation scripts - it bypasses the pipeline, can’t be captured, redirected, or suppressed by callers. Use Write-Information for log lines, Write-Verbose for diagnostics. Reserve Write-Host for interactive UI output (colour, banners) only.

  • 🚨 Swallowing errors with a bare catch {} - an empty catch block silently hides failures. At minimum, rethrow with throw or Write-Error $_ -ErrorAction Stop. If you genuinely want to ignore an error, be explicit: catch { Write-Verbose "Ignored: $_" }.

  • ⚠️ -ErrorAction SilentlyContinue used broadly - it suppresses all errors from a cmdlet, not just expected ones. Use it surgically on specific calls where a missing resource is a known-valid state, and check $? or $Error afterwards.

  • ⚠️ Piping to Format-Table or Format-List inside functions - once formatted, objects become strings and can’t be processed by downstream callers. Format only at the top-level call site or in a script’s final output block.

  • 🚨 Invoke-Expression (IEX) for dynamic strings - it’s a code injection vector. Build command arrays and use the call operator & $cmd @args instead.

  • ⚠️ $ErrorActionPreference = 'SilentlyContinue' set globally - set it to 'Stop' at script top and adjust per-call. Global silencing turns real failures into silent no-ops that are painful to debug.

  • 🔬 Checking $? after PowerShell cmdlet calls - $? reflects the last cmdlet’s success, but with $ErrorActionPreference = 'Stop', errors throw before you reach the check. Use $? only for native executables where $LASTEXITCODE also applies.

Last updated on