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
#!/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 debuggingStandard script parameter block
[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
$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-InformationvsWrite-HostvsWrite-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
| Cmdlet | Stream # | When to use | Honours preference var |
|---|---|---|---|
Write-Output | 1 (stdout) | The script’s actual return data | n/a |
Write-Error | 2 | Recoverable failure - the operation didn’t work | $ErrorActionPreference |
Write-Warning | 3 | Something to flag but execution continues | $WarningPreference |
Write-Verbose | 4 | Detailed diagnostics, off by default | $VerbosePreference / -Verbose |
Write-Debug | 5 | Developer-only deep diagnostics | $DebugPreference / -Debug |
Write-Information | 6 | Structured info events - preferred over Write-Host | $InformationPreference / -InformationAction |
Write-Host | 6 (info) | UI output only - colour, banners, prompts | No |
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.
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 -VerboseLeveled wrapper - timestamped, stream-correct
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.
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
# 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>&2Module-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.
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/-InformationActionfor free. - Set
$ErrorActionPreference = 'Stop'so terminating errors propagate predictably. - Prefer
Write-InformationoverWrite-Hostfor log lines; reserveWrite-Hostfor coloured UI output. Write-Verbosefor any diagnostic the user would only want when troubleshooting.- Use
Write-Error -ErrorAction Stop(orthrow) insidecatchblocks - bareWrite-Erroris non-terminating. - In CI/containers, emit JSON via
Write-LogJsonso log shippers can parse fields.
Core Language
String formatting
$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
# 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
# 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
# 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
# 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
$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 # 8080Converting to/from hashtable
# 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-JsonType names and default display
# 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 columnsSorting, grouping, and comparing
# 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, StatusUsing PSCustomObjects as function output
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' -NoTypeInformationPipeline patterns
# 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 5Error handling
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.
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
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 / catch | trap | |
|---|---|---|
| Scope | Tied to a specific try block | Entire enclosing scope (function, script) |
| Specificity | Per-block, ordered type matching | Single handler per exception type per scope |
| Flow control | Re-throw with throw; swallow by not throwing | break exits the scope, continue resumes after the error line |
| Nesting | Inner try shadows outer | Inner scope trap shadows outer scope |
| When to use | Structured error handling in functions and scripts | Script-level safety nets; cleanup-on-exit guards |
# 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
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
# 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
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
[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
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
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 -VerboseShouldProcess / WhatIf / Confirm
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 confirmationRetry helper
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
# 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-JsonPath manipulation
# 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
# 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
# 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)
# 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)
# 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-TableBackground jobs
$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 $jobForEach-Object -Parallel
$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-TableAzure Authentication
Service principal (client secret) ⚠️ Legacy - prefer managed identity or OIDC federation
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)
# 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)
# 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 $tokenSwitch subscription context
# 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 $ctxSee 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
# 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, ResourceGroupNameKey Vault secrets
# 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
# 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)
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
# 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 $storageCtxApp Registrations & Service Principals
# 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
# 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, IdAzure Policy
# 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' -AsJobProgress & Output Formatting
Progress bar
$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' -CompletedOutput formatting
# 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' -PassThruCI/CD & Pipeline Integration
GitHub Actions integration
# 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
# 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
$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
# 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
# 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 $configMocking patterns
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
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
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
# 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, MessageModule boilerplate
# 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
# 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
. $PROFILEUseful profile additions
# 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)
$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.tomlUseful Global Patterns
Confirm before destructive action
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
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_IDScript execution time
$stopwatch = [System.Diagnostics.Stopwatch]::StartNew()
# ... your script logic ...
$stopwatch.Stop()
Write-Host "Completed in $($stopwatch.Elapsed.ToString('mm\:ss\.ff'))" -ForegroundColor CyanBulk parallel ARM deployments
$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, propertiesAnti-patterns
-
🚨
Write-Hostin modules or automation scripts - it bypasses the pipeline, can’t be captured, redirected, or suppressed by callers. UseWrite-Informationfor log lines,Write-Verbosefor diagnostics. ReserveWrite-Hostfor interactive UI output (colour, banners) only. -
🚨 Swallowing errors with a bare
catch {}- an empty catch block silently hides failures. At minimum, rethrow withthroworWrite-Error $_ -ErrorAction Stop. If you genuinely want to ignore an error, be explicit:catch { Write-Verbose "Ignored: $_" }. -
⚠️
-ErrorAction SilentlyContinueused 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$Errorafterwards. -
⚠️ Piping to
Format-TableorFormat-Listinside 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 @argsinstead. -
⚠️
$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$LASTEXITCODEalso applies.