Files
TestRepo/PowerShell_How2_Adv.md

1621 lines
52 KiB
Markdown
Raw Permalink Normal View History

2026-03-11 11:29:14 +00:00
# Advanced PowerShell — L3 / SME Reference
> Deep-dive techniques for senior engineers and subject matter experts. Assumes solid PowerShell fundamentals.
> Covers automation architecture, performance, security, internals, and enterprise-scale patterns.
---
## Table of Contents
1. [Script Architecture & Module Design](#script-architecture--module-design)
2. [Advanced Functions & Parameter Binding](#advanced-functions--parameter-binding)
3. [The PowerShell Pipeline — Internals](#the-powershell-pipeline--internals)
4. [Classes & Object-Oriented PowerShell](#classes--object-oriented-powershell)
5. [Runspaces & Parallel Execution](#runspaces--parallel-execution)
6. [Desired State Configuration (DSC)](#desired-state-configuration-dsc)
7. [Just Enough Administration (JEA)](#just-enough-administration-jea)
8. [Secrets & Credential Management](#secrets--credential-management)
9. [PowerShell Remoting — Advanced Patterns](#powershell-remoting--advanced-patterns)
10. [Logging, Auditing & Transcription](#logging-auditing--transcription)
11. [Error Handling — Production Patterns](#error-handling--production-patterns)
12. [Regular Expressions in PowerShell](#regular-expressions-in-powershell)
13. [Working with APIs & REST](#working-with-apis--rest)
14. [Working with Databases](#working-with-databases)
15. [XML, JSON & YAML Manipulation](#xml-json--yaml-manipulation)
16. [Performance Optimisation](#performance-optimisation)
17. [Security & Constrained Language Mode](#security--constrained-language-mode)
18. [Working with .NET Directly](#working-with-net-directly)
19. [PowerShell Providers & PSDrives](#powershell-providers--psdrives)
20. [Enterprise Automation Patterns](#enterprise-automation-patterns)
21. [Debugging & Diagnostics](#debugging--diagnostics)
22. [CI/CD Integration for PowerShell](#cicd-integration-for-powershell)
---
## Script Architecture & Module Design
### Module Structure (Production Standard)
```
MyModule/
├── MyModule.psd1 # Module manifest (the "front door")
├── MyModule.psm1 # Root module — loads everything
├── Public/ # Exported functions (visible to users)
│ ├── Get-ServerHealth.ps1
│ └── Set-ServerConfig.ps1
├── Private/ # Internal helper functions (not exported)
│ ├── Write-Log.ps1
│ └── Test-Connectivity.ps1
├── Classes/ # PS classes (loaded before functions)
│ └── ServerConfig.ps1
├── Data/ # Static data, config templates
│ └── defaults.psd1
└── Tests/ # Pester tests
├── Get-ServerHealth.Tests.ps1
└── Set-ServerConfig.Tests.ps1
```
### Module Manifest (psd1) — Key Fields
```powershell
@{
ModuleVersion = '2.1.0'
GUID = 'a1b2c3d4-...' # New-Guid to generate
Author = 'Infra Team'
Description = 'Enterprise server management module'
PowerShellVersion = '5.1'
RootModule = 'MyModule.psm1'
# Only export what you intend to be public API
FunctionsToExport = @('Get-ServerHealth', 'Set-ServerConfig')
CmdletsToExport = @()
VariablesToExport = @()
AliasesToExport = @()
# Declare external module dependencies
RequiredModules = @(
@{ ModuleName = 'ActiveDirectory'; ModuleVersion = '1.0.0' }
)
PrivateData = @{
PSData = @{
Tags = @('Infrastructure', 'Server', 'Automation')
ProjectUri = 'https://github.com/corp/MyModule'
}
}
}
```
### Root Module Loader (psm1)
```powershell
# Load classes FIRST — functions may depend on them
foreach ($class in Get-ChildItem "$PSScriptRoot\Classes\*.ps1") {
. $class.FullName
}
# Load private helpers
foreach ($private in Get-ChildItem "$PSScriptRoot\Private\*.ps1") {
. $private.FullName
}
# Load public functions
foreach ($public in Get-ChildItem "$PSScriptRoot\Public\*.ps1") {
. $public.FullName
}
# Export only Public functions — Private stay hidden
Export-ModuleMember -Function (Get-ChildItem "$PSScriptRoot\Public\*.ps1").BaseName
```
> **Why this pattern?** It gives you clean separation of public API vs internals, makes Pester testing straightforward, and lets multiple developers work on different functions without merge conflicts on a single large .psm1 file.
---
## Advanced Functions & Parameter Binding
### Full CmdletBinding Template
```powershell
function Invoke-ServerMaintenance {
[CmdletBinding(
SupportsShouldProcess = $true, # Enables -WhatIf and -Confirm
ConfirmImpact = 'High', # Triggers confirm prompt by default
DefaultParameterSetName = 'ByName'
)]
param (
# Parameter sets let one function handle multiple input patterns
[Parameter(Mandatory, ValueFromPipeline, ValueFromPipelineByPropertyName,
ParameterSetName = 'ByName', Position = 0)]
[ValidateNotNullOrEmpty()]
[string[]]$ComputerName,
[Parameter(Mandatory, ParameterSetName = 'BySession')]
[System.Management.Automation.Runspaces.PSSession[]]$Session,
[Parameter()]
[ValidateSet('Reboot','DrainConnections','RunChecks')]
[string]$Action = 'RunChecks',
[Parameter()]
[ValidateRange(1, 300)]
[int]$TimeoutSeconds = 60,
[Parameter()]
[ValidateScript({
if (Test-Path $_) { $true }
else { throw "Log path '$_' does not exist." }
})]
[string]$LogPath,
# Switch params — presence means $true
[switch]$Force
)
begin {
# Runs ONCE before pipeline input — initialise, open connections, etc.
Write-Verbose "[$($MyInvocation.MyCommand)] Starting — Action: $Action"
$results = [System.Collections.Generic.List[PSObject]]::new()
}
process {
# Runs ONCE PER PIPELINE OBJECT
foreach ($computer in $ComputerName) {
# ShouldProcess check — respects -WhatIf and -Confirm
if ($PSCmdlet.ShouldProcess($computer, "Perform $Action")) {
try {
# ... do work ...
$results.Add([PSCustomObject]@{
ComputerName = $computer
Action = $Action
Success = $true
Timestamp = Get-Date
})
}
catch {
Write-Error "Failed on $computer`: $_"
}
}
}
}
end {
# Runs ONCE after all pipeline input — finalise, close connections, output
Write-Verbose "[$($MyInvocation.MyCommand)] Completed. Processed $($results.Count) servers."
$results
}
}
```
### Dynamic Parameters
```powershell
# Dynamic params are added at runtime based on other parameter values
function Get-EnvironmentConfig {
[CmdletBinding()]
param (
[ValidateSet('Production','Staging','Dev')]
[string]$Environment
)
DynamicParam {
$paramDictionary = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new()
if ($Environment -eq 'Production') {
$attr = [System.Management.Automation.ParameterAttribute]@{ Mandatory = $true }
$validateSet = [System.Management.Automation.ValidateSetAttribute]::new('FullAudit','ReadOnly')
$collection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new()
$collection.Add($attr)
$collection.Add($validateSet)
$dynParam = [System.Management.Automation.RuntimeDefinedParameter]::new(
'AccessMode', [string], $collection
)
$paramDictionary.Add('AccessMode', $dynParam)
}
return $paramDictionary
}
process {
$accessMode = $PSBoundParameters['AccessMode']
Write-Output "Environment: $Environment | AccessMode: $accessMode"
}
}
```
### Parameter Validation Attributes (Full List)
```powershell
[ValidateNotNull()] # Not $null
[ValidateNotNullOrEmpty()] # Not $null or ""
[ValidateSet('A','B','C')] # Must be one of these values
[ValidateRange(1, 100)] # Numeric range
[ValidateLength(3, 50)] # String length range
[ValidatePattern('^[A-Z]{3}\d{4}$')] # Must match regex
[ValidateCount(1, 10)] # Array element count
[ValidateScript({ $_ -gt 0 })] # Custom validation logic
[AllowNull()] # Explicitly permit $null
[AllowEmptyString()] # Explicitly permit ""
[AllowEmptyCollection()] # Explicitly permit @()
```
---
## The PowerShell Pipeline — Internals
### How the Pipeline Actually Works
```powershell
# The pipeline streams objects one at a time — it does NOT buffer everything first.
# This is crucial for memory efficiency with large datasets.
# BAD — loads ALL 100k objects into memory, then sorts
Get-Content "C:\Logs\huge.log" | Sort-Object # OOM risk on large files
# GOOD — stream-process without full load into memory
Get-Content "C:\Logs\huge.log" | ForEach-Object {
if ($_ -match "ERROR") { $_ }
}
```
### WriteObject vs Return in the Pipeline
```powershell
function Get-Items {
process {
# $PSCmdlet.WriteObject() sends to pipeline WITHOUT collecting into array
# This is what PowerShell does internally when you just output an object
foreach ($i in 1..5) {
[PSCustomObject]@{ Id = $i; Value = $i * 2 }
# ^ Each object hits the pipeline immediately — next cmdlet can process it
}
# Using 'return' or wrapping in @() forces array buffering — avoid in hot loops
}
}
```
### SteppablePipeline — Proxy Functions
```powershell
# Wrap an existing cmdlet to intercept/modify its pipeline behaviour
function Get-Process {
[CmdletBinding()]
param([string]$Name)
begin {
# Get metadata for the real Get-Process
$wrappedCmd = $ExecutionContext.InvokeCommand.GetCommand(
'Microsoft.PowerShell.Management\Get-Process', [System.Management.Automation.CommandTypes]::Cmdlet
)
$scriptCmd = { & $wrappedCmd @PSBoundParameters }
$steppable = $scriptCmd.GetSteppablePipeline($myInvocation.CommandOrigin)
$steppable.Begin($PSCmdlet)
}
process {
# Intercept — add custom logic here before/after forwarding
$steppable.Process($_)
}
end {
$steppable.End()
}
}
```
> Proxy functions are powerful for **adding logging, telemetry, or parameter defaults** to built-in cmdlets without breaking existing scripts that call them.
---
## Classes & Object-Oriented PowerShell
### Full Class Pattern
```powershell
# Classes must be defined BEFORE they are used (dot-source or load in begin{})
class ServerConfig {
# Properties with types
[string] $Hostname
[string] $Environment
[int] $MaxConnections
[bool] $IsProduction
[datetime] $LastChecked
# Hidden property — not shown in default output
hidden [string] $_internalId
# Static property — shared across all instances
static [string] $DefaultEnvironment = 'Development'
# Constructor
ServerConfig([string]$hostname, [string]$environment) {
$this.Hostname = $hostname
$this.Environment = $environment
$this.MaxConnections = 100
$this.IsProduction = ($environment -eq 'Production')
$this.LastChecked = Get-Date
$this._internalId = [System.Guid]::NewGuid().ToString()
}
# Default constructor
ServerConfig() : base() {
$this.Environment = [ServerConfig]::DefaultEnvironment
}
# Method
[bool] IsHealthy() {
return (Test-Connection $this.Hostname -Count 1 -Quiet)
}
# Static method
static [ServerConfig] FromHashtable([hashtable]$data) {
$config = [ServerConfig]::new($data.Hostname, $data.Environment)
$config.MaxConnections = $data.MaxConnections ?? 100
return $config
}
# Override ToString for readable output
[string] ToString() {
return "[$($this.Environment)] $($this.Hostname)"
}
}
# Inheritance
class WebServerConfig : ServerConfig {
[int] $HttpPort = 80
[int] $HttpsPort = 443
[string] $SiteName
WebServerConfig([string]$hostname, [string]$siteName) : base($hostname, 'Production') {
$this.SiteName = $siteName
}
[bool] IsSslConfigured() {
return (Test-NetConnection $this.Hostname -Port $this.HttpsPort -WarningAction SilentlyContinue).TcpTestSucceeded
}
}
# Usage
$srv = [ServerConfig]::new('SERVER01', 'Production')
$web = [WebServerConfig]::new('WEB01', 'MainSite')
$web.IsHealthy()
$web.IsSslConfigured()
```
### Implementing Interfaces via Classes
```powershell
# PowerShell classes can implement .NET interfaces
class LogEntry : System.IComparable {
[datetime] $Timestamp
[string] $Message
[string] $Severity
LogEntry([string]$message, [string]$severity) {
$this.Timestamp = Get-Date
$this.Message = $message
$this.Severity = $severity
}
# Required by IComparable — enables Sort-Object on collections
[int] CompareTo([object]$other) {
return $this.Timestamp.CompareTo(([LogEntry]$other).Timestamp)
}
}
$entries = @(
[LogEntry]::new("System started", "INFO")
[LogEntry]::new("Disk low", "WARN")
)
$entries | Sort-Object # Now works correctly via IComparable
```
---
## Runspaces & Parallel Execution
### ForEach-Object -Parallel (PowerShell 7+)
```powershell
# Clean and simple for most parallel work
$servers = @("SERVER01","SERVER02","SERVER03","SERVER04","SERVER05")
$results = $servers | ForEach-Object -Parallel {
$server = $_
$ping = Test-Connection $server -Count 1 -Quiet
[PSCustomObject]@{
Server = $server
Online = $ping
Time = Get-Date
}
} -ThrottleLimit 10 # Max concurrent threads (default 5)
# Pass variables into parallel scope with $using:
$timeout = 30
$servers | ForEach-Object -Parallel {
Test-NetConnection $_ -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue
# Access outer scope variable
Start-Sleep -Milliseconds $using:timeout
} -ThrottleLimit 5
```
### Runspace Pools (PowerShell 5.1 Compatible)
```powershell
# More control, works on PS 5.1 — needed in enterprise environments
$scriptBlock = {
param($Server, $Timeout)
try {
$result = Test-Connection $Server -Count 1 -Quiet -ErrorAction Stop
[PSCustomObject]@{ Server = $Server; Online = $result; Error = $null }
}
catch {
[PSCustomObject]@{ Server = $Server; Online = $false; Error = $_.Exception.Message }
}
}
$servers = @("SERVER01","SERVER02","SERVER03","SERVER04","SERVER05")
$maxThreads = 10
# Create and open the runspace pool
$pool = [RunspaceFactory]::CreateRunspacePool(1, $maxThreads)
$pool.Open()
# Spin up all jobs
$jobs = foreach ($server in $servers) {
$ps = [PowerShell]::Create()
$ps.RunspacePool = $pool
[void]$ps.AddScript($scriptBlock)
[void]$ps.AddArgument($server)
[void]$ps.AddArgument(30)
[PSCustomObject]@{
PowerShell = $ps
Handle = $ps.BeginInvoke()
Server = $server
}
}
# Collect results as they complete
$results = foreach ($job in $jobs) {
$job.PowerShell.EndInvoke($job.Handle)
$job.PowerShell.Dispose()
}
$pool.Close()
$pool.Dispose()
$results | Format-Table -AutoSize
```
> Runspace pools are the **gold standard for high-performance parallel work in PS 5.1**. Unlike Start-Job (which spawns new processes), runspaces share the same process — much lower overhead. Ideal for scanning hundreds of servers simultaneously.
### Thread-Safe Collections for Parallel Work
```powershell
# Regular arrays/lists are NOT thread-safe — use these in parallel scenarios
$bag = [System.Collections.Concurrent.ConcurrentBag[PSObject]]::new()
$queue = [System.Collections.Concurrent.ConcurrentQueue[string]]::new()
$dict = [System.Collections.Concurrent.ConcurrentDictionary[string,object]]::new()
# Example: thread-safe result collection
$servers | ForEach-Object -Parallel {
$results = $using:bag
$results.Add([PSCustomObject]@{ Server = $_; Time = Get-Date })
} -ThrottleLimit 10
```
---
## Desired State Configuration (DSC)
### DSC Configuration — Real-World Example
```powershell
Configuration WebServerBaseline {
param (
[string[]]$ComputerName = 'localhost',
[PSCredential]$Credential
)
Import-DscResource -ModuleName PSDesiredStateConfiguration
Import-DscResource -ModuleName xWebAdministration
Node $ComputerName {
# Ensure IIS is installed
WindowsFeature IIS {
Ensure = 'Present'
Name = 'Web-Server'
}
WindowsFeature IISManagement {
Ensure = 'Present'
Name = 'Web-Mgmt-Tools'
DependsOn = '[WindowsFeature]IIS'
}
# Ensure a service is running
Service W3SVC {
Name = 'W3SVC'
State = 'Running'
StartupType = 'Automatic'
DependsOn = '[WindowsFeature]IIS'
}
# Ensure a file exists with specific content
File AppConfig {
Ensure = 'Present'
DestinationPath = 'C:\inetpub\wwwroot\app.config'
Contents = '<configuration><appSettings /></configuration>'
Type = 'File'
}
# Registry setting
Registry TLS12 {
Ensure = 'Present'
Key = 'HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.2\Server'
ValueName = 'Enabled'
ValueData = '1'
ValueType = 'Dword'
}
}
}
# Compile to MOF
WebServerBaseline -ComputerName SERVER01, SERVER02 -OutputPath C:\DSC\WebBaseline
# Apply — push mode
Start-DscConfiguration -Path C:\DSC\WebBaseline -ComputerName SERVER01 -Wait -Verbose -Force
# Test compliance without changing anything
Test-DscConfiguration -ComputerName SERVER01
# Get current state vs desired state
Get-DscConfiguration -CimSession (New-CimSession SERVER01)
Get-DscConfigurationStatus -CimSession (New-CimSession SERVER01)
```
---
## Just Enough Administration (JEA)
> JEA creates **constrained PowerShell endpoints** that let users run specific commands with elevated privileges — without giving them full admin rights or interactive sessions.
### Role Capability File
```powershell
# Create the role capability file template
New-PSRoleCapabilityFile -Path "C:\JEA\Roles\HelpDesk.psrc"
```
```powershell
# HelpDesk.psrc — what this role is allowed to do
@{
# Visible cmdlets — everything else is hidden
VisibleCmdlets = @(
'Get-Service',
'Get-Process',
'Get-EventLog',
@{ Name = 'Restart-Service'; Parameters = @{ Name = 'Name'; ValidateSet = 'Spooler','W3SVC','WinRM' } },
@{ Name = 'Stop-Process'; Parameters = @{ Name = 'Name'; ValidatePattern = '^(notepad|calc)$' } }
)
# Functions you define below that are available
VisibleFunctions = @('Get-ServerSummary')
# External executables permitted
VisibleExternalCommands = @('C:\Windows\System32\ipconfig.exe')
# PowerShell providers visible (restrict to FileSystem only if needed)
VisibleProviders = @('FileSystem', 'Registry')
# Define helper functions inline
FunctionDefinitions = @(
@{
Name = 'Get-ServerSummary'
ScriptBlock = {
[PSCustomObject]@{
Hostname = $env:COMPUTERNAME
Uptime = (Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime
TopCPU = Get-Process | Sort-Object CPU -Descending | Select-Object -First 3 Name, CPU
}
}
}
)
}
```
### Session Configuration File
```powershell
# Create the session config
New-PSSessionConfigurationFile -Path "C:\JEA\HelpDeskSession.pssc" `
-SessionType RestrictedRemoteServer `
-RunAsVirtualAccount `
-RoleDefinitions @{
'DOMAIN\HelpDesk' = @{ RoleCapabilities = 'HelpDesk' }
'DOMAIN\L2Support' = @{ RoleCapabilities = 'HelpDesk', 'NetworkAdmin' }
} `
-TranscriptDirectory 'C:\JEA\Transcripts' `
-LanguageMode ConstrainedLanguage
# Register the endpoint on each managed server
Register-PSSessionConfiguration -Name 'HelpDeskEndpoint' `
-Path 'C:\JEA\HelpDeskSession.pssc' -Force
# Test it — connect as a regular user
Enter-PSSession -ComputerName SERVER01 -ConfigurationName HelpDeskEndpoint
# Audit what a user can do at an endpoint
$session = New-PSSession -ComputerName SERVER01 -ConfigurationName HelpDeskEndpoint
Invoke-Command -Session $session -ScriptBlock { Get-Command }
```
---
## Secrets & Credential Management
### SecretManagement Module (Modern Standard)
```powershell
# Install
Install-Module Microsoft.PowerShell.SecretManagement
Install-Module Microsoft.PowerShell.SecretStore # Local encrypted vault
# Register a vault
Register-SecretVault -Name 'CorpVault' -ModuleName 'Microsoft.PowerShell.SecretStore'
# Store secrets
Set-Secret -Name 'SQLServiceAccount' -Secret (Get-Credential)
Set-Secret -Name 'APIToken' -Secret 'abc123supersecret'
# Retrieve in scripts — no plain text, no hard-coding
$cred = Get-Secret -Name 'SQLServiceAccount'
$apiToken = Get-Secret -Name 'APIToken' -AsPlainText
# List all secrets (names only — values stay encrypted)
Get-SecretInfo -Vault 'CorpVault'
```
### Encrypting Strings for Script Storage
```powershell
# Encrypt — only the SAME USER on the SAME MACHINE can decrypt (DPAPI)
$secureString = Read-Host "Enter secret" -AsSecureString
$encrypted = ConvertFrom-SecureString $secureString
$encrypted | Out-File "C:\Scripts\.secret" # Store encrypted string
# Decrypt at runtime
$encrypted = Get-Content "C:\Scripts\.secret"
$secure = ConvertTo-SecureString $encrypted
$credential = New-Object System.Management.Automation.PSCredential("svcAccount", $secure)
# For cross-machine/service-account use — AES key approach
$key = (1..32 | ForEach-Object { Get-Random -Minimum 0 -Maximum 256 })
$key | Export-Clixml "C:\Secure\aes.key" # Protect this file with NTFS permissions!
$encrypted = ConvertFrom-SecureString $secureString -Key $key
$decrypted = ConvertTo-SecureString $encrypted -Key (Import-Clixml "C:\Secure\aes.key")
```
> **Never** store plain-text passwords in scripts. Use SecretManagement for modern deployments, or DPAPI/AES encryption as a fallback. Protect AES key files with strict NTFS ACLs.
---
## PowerShell Remoting — Advanced Patterns
### ConstrainedLanguage Remoting & Custom Endpoints
```powershell
# Create a custom endpoint with specific module access
Register-PSSessionConfiguration -Name 'InfraTools' `
-StartupScript 'C:\Scripts\LoadInfraModule.ps1' `
-RunAsCredential 'DOMAIN\svcInfraTools' `
-AccessMode Remote `
-Force
# Connect to a named endpoint
Enter-PSSession -ComputerName SERVER01 -ConfigurationName 'InfraTools'
```
### SSH Tunnelling for Remoting
```powershell
# Forward local port 15985 to remote server's WinRM port via SSH jump host
# Run in a separate terminal / background job
ssh -L 15985:TARGET_SERVER:5985 jumphost.corp.com -N
# Now connect through the tunnel
$session = New-PSSession -ComputerName localhost -Port 15985 -Credential (Get-Credential)
Invoke-Command -Session $session -ScriptBlock { hostname }
```
### Fan-Out Pattern — Run Against Many Servers Efficiently
```powershell
function Invoke-ParallelRemote {
param (
[string[]]$ComputerName,
[scriptblock]$ScriptBlock,
[PSCredential]$Credential,
[int]$BatchSize = 50,
[int]$ThrottleLimit = 32
)
# Process in batches to avoid overwhelming WinRM
$batches = for ($i = 0; $i -lt $ComputerName.Count; $i += $BatchSize) {
, ($ComputerName[$i..([Math]::Min($i + $BatchSize - 1, $ComputerName.Count - 1))])
}
foreach ($batch in $batches) {
$params = @{
ComputerName = $batch
ScriptBlock = $ScriptBlock
ThrottleLimit = $ThrottleLimit
ErrorAction = 'SilentlyContinue'
}
if ($Credential) { $params['Credential'] = $Credential }
Invoke-Command @params
}
}
# Usage — hit 500 servers without melting WinRM
$allServers = Get-ADComputer -Filter {OperatingSystem -like "*Server*"} | Select-Object -Expand Name
Invoke-ParallelRemote -ComputerName $allServers -ScriptBlock {
[PSCustomObject]@{
Server = $env:COMPUTERNAME
FreeGB = [math]::Round((Get-PSDrive C).Free / 1GB, 1)
Uptime = ((Get-Date) - (gcim Win32_OperatingSystem).LastBootUpTime).Days
}
}
```
---
## Logging, Auditing & Transcription
### Structured Logging Function
```powershell
enum LogLevel { DEBUG; INFO; WARN; ERROR; FATAL }
function Write-StructuredLog {
[CmdletBinding()]
param (
[Parameter(Mandatory)][string]$Message,
[LogLevel]$Level = [LogLevel]::INFO,
[string]$LogFile = "C:\Logs\$($MyInvocation.ScriptName -replace '.*\\').log",
[hashtable]$Properties = @{}
)
$entry = [ordered]@{
Timestamp = (Get-Date).ToString('yyyy-MM-ddTHH:mm:ss.fffZ')
Level = $Level.ToString()
Host = $env:COMPUTERNAME
User = $env:USERNAME
PID = $PID
Message = $Message
}
$Properties.GetEnumerator() | ForEach-Object { $entry[$_.Key] = $_.Value }
# JSON to log file — machine-parseable by Splunk, ELK, etc.
$entry | ConvertTo-Json -Compress | Add-Content $LogFile
# Also write to console at appropriate stream
switch ($Level) {
'DEBUG' { Write-Debug $Message }
'INFO' { Write-Verbose $Message }
'WARN' { Write-Warning $Message }
'ERROR' { Write-Error $Message }
'FATAL' { Write-Error $Message; throw $Message }
}
}
# Usage
Write-StructuredLog "Starting deployment" -Level INFO -Properties @{ Server = "WEB01"; Version = "2.1" }
Write-StructuredLog "Disk space low" -Level WARN -Properties @{ FreeGB = 2.1; Threshold = 5 }
```
### Enable PowerShell Script Block Logging (Group Policy / Registry)
```powershell
# Enable on a target server — logs ALL executed script blocks to Event Log
Invoke-Command -ComputerName SERVER01 -ScriptBlock {
$path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\ScriptBlockLogging'
New-Item $path -Force | Out-Null
Set-ItemProperty $path -Name EnableScriptBlockLogging -Value 1
Set-ItemProperty $path -Name EnableScriptBlockInvocationLogging -Value 1
}
# Read script block logs (Event ID 4104)
Get-WinEvent -ComputerName SERVER01 -FilterHashtable @{
LogName = 'Microsoft-Windows-PowerShell/Operational'
Id = 4104
} | Select-Object TimeCreated, @{N="Script";E={$_.Properties[2].Value}} | Select-Object -First 20
```
### Transcript Logging
```powershell
# Start transcript — logs all input and output
Start-Transcript -Path "C:\Logs\session-$(Get-Date -Format yyyyMMdd-HHmmss).log" -Append
# Configure automatic transcription via Group Policy or registry
$path = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\PowerShell\Transcription'
New-Item $path -Force | Out-Null
Set-ItemProperty $path -Name EnableTranscripting -Value 1
Set-ItemProperty $path -Name EnableInvocationHeader -Value 1
Set-ItemProperty $path -Name OutputDirectory -Value '\\LOGSERVER\PSTranscripts$'
```
---
## Error Handling — Production Patterns
### Hierarchical Error Handling
```powershell
function Invoke-SafeOperation {
[CmdletBinding()]
param([string]$ComputerName, [scriptblock]$Operation)
$ErrorActionPreference = 'Stop' # Make ALL errors terminating within function
try {
Invoke-Command -ComputerName $ComputerName -ScriptBlock $Operation
}
catch [System.Management.Automation.Remoting.PSRemotingTransportException] {
# Specific catch for WinRM/connectivity failures
Write-StructuredLog "Cannot reach $ComputerName — WinRM transport failure" -Level WARN
Write-Error "Transport failure: $($_.Exception.Message)" -ErrorAction Continue
}
catch [System.UnauthorizedAccessException] {
Write-StructuredLog "Access denied to $ComputerName" -Level ERROR
throw # Re-throw — caller needs to know
}
catch {
# Capture full error context for diagnostics
$errorDetail = [PSCustomObject]@{
Message = $_.Exception.Message
Type = $_.Exception.GetType().FullName
ScriptLine = $_.InvocationInfo.ScriptLineNumber
Command = $_.InvocationInfo.MyCommand
StackTrace = $_.ScriptStackTrace
}
Write-StructuredLog "Unhandled error on $ComputerName" -Level ERROR -Properties @{
ErrorType = $errorDetail.Type
ErrorMsg = $errorDetail.Message
}
throw
}
finally {
# Always runs — cleanup, close connections, release locks
Write-Verbose "Operation on $ComputerName completed (success or fail)"
}
}
```
### $ErrorActionPreference vs -ErrorAction
```powershell
# $ErrorActionPreference = global default for the scope
$ErrorActionPreference = 'Stop' # All errors terminating in this scope
# -ErrorAction = per-cmdlet override
Get-Item "badpath" -ErrorAction SilentlyContinue # Suppress THIS error only
# -ErrorVariable = capture errors without stopping
Get-Item "badpath" -ErrorAction SilentlyContinue -ErrorVariable capturedErr
if ($capturedErr) { Write-Warning "Item not found: $capturedErr" }
# $? = boolean success of last command
Get-Service "FakeService" -ErrorAction SilentlyContinue
if (-not $?) { Write-Warning "Last command failed" }
```
---
## Regular Expressions in PowerShell
```powershell
# -match operator — sets $Matches automatic variable
"Server: WEB01 IP: 10.0.0.50" -match 'IP:\s+(\d{1,3}(?:\.\d{1,3}){3})'
$Matches[0] # Full match: "IP: 10.0.0.50"
$Matches[1] # Capture group 1: "10.0.0.50"
# Named capture groups — much cleaner than numbered groups
"EventID: 4625 Source: Security" -match 'EventID:\s+(?<id>\d+)\s+Source:\s+(?<source>\w+)'
$Matches['id'] # "4625"
$Matches['source'] # "Security"
# -replace with regex
"Server_PROD_WEB01" -replace '_(?<env>PROD|DEV|UAT)_', ' [$env:env] '
# Select-String — regex on file content (like grep)
Select-String -Path "C:\Logs\*.log" -Pattern "ERROR|FATAL" -CaseSensitive
Select-String -Path "C:\Logs\app.log" -Pattern '(?<date>\d{4}-\d{2}-\d{2}).*ERROR' |
ForEach-Object { $_.Matches[0].Groups['date'].Value }
# [regex] class — when you need full regex engine control
$regex = [regex]::new('(?<ip>\d{1,3}(?:\.\d{1,3}){3})', 'IgnoreCase,Compiled')
$content = Get-Content "C:\Logs\firewall.log" -Raw
$regex.Matches($content) | ForEach-Object { $_.Groups['ip'].Value } | Sort-Object -Unique
# Replace with a MatchEvaluator (transform each match)
$result = [regex]::Replace("server01,server02,server03", '\b\w+\b', {
param($match)
$match.Value.ToUpper()
})
# Result: "SERVER01,SERVER02,SERVER03"
```
---
## Working with APIs & REST
### Invoke-RestMethod — Full Pattern
```powershell
# Build reusable API client function
function Invoke-CorpAPI {
[CmdletBinding()]
param (
[string]$Endpoint,
[string]$Method = 'GET',
[hashtable]$Body = @{},
[string]$BaseUri = 'https://api.corp.com/v2'
)
# Retrieve token from SecretManagement
$token = Get-Secret -Name 'CorpAPIToken' -AsPlainText
$params = @{
Uri = "$BaseUri/$Endpoint"
Method = $Method
Headers = @{
'Authorization' = "Bearer $token"
'Content-Type' = 'application/json'
'X-Request-ID' = [System.Guid]::NewGuid().ToString()
}
UseBasicParsing = $true
ErrorAction = 'Stop'
}
if ($Method -in 'POST','PUT','PATCH') {
$params['Body'] = ($Body | ConvertTo-Json -Depth 10)
}
try {
$response = Invoke-RestMethod @params
return $response
}
catch {
$statusCode = $_.Exception.Response.StatusCode.Value__
$detail = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
throw "API error $statusCode on $Endpoint`: $($detail.message ?? $_.Exception.Message)"
}
}
# Pagination pattern
function Get-AllPages {
param([string]$Endpoint)
$page = 1
$results = [System.Collections.Generic.List[PSObject]]::new()
do {
$response = Invoke-CorpAPI -Endpoint "$Endpoint`?page=$page&per_page=100"
$results.AddRange($response.items)
$page++
} while ($response.has_more)
return $results
}
```
### Working with Graph API (Microsoft 365 / Azure AD)
```powershell
# Authenticate with client credentials (app registration)
function Get-GraphToken {
param($TenantId, $ClientId, $ClientSecret)
$body = @{
grant_type = 'client_credentials'
client_id = $ClientId
client_secret = $ClientSecret
scope = 'https://graph.microsoft.com/.default'
}
$response = Invoke-RestMethod "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" `
-Method POST -Body $body
return $response.access_token
}
$token = Get-GraphToken -TenantId $tid -ClientId $cid -ClientSecret $secret
$headers = @{ Authorization = "Bearer $token" }
# Get all users with specific properties
$users = Invoke-RestMethod `
"https://graph.microsoft.com/v1.0/users?`$select=displayName,mail,jobTitle&`$top=999" `
-Headers $headers
# Handle nextLink pagination
$allUsers = [System.Collections.Generic.List[PSObject]]::new()
$uri = "https://graph.microsoft.com/v1.0/users?`$top=999"
do {
$page = Invoke-RestMethod $uri -Headers $headers
$allUsers.AddRange($page.value)
$uri = $page.'@odata.nextLink'
} while ($uri)
```
---
## Working with Databases
### SQL Server — System.Data.SqlClient
```powershell
function Invoke-SqlQuery {
param (
[string]$ServerInstance,
[string]$Database,
[string]$Query,
[hashtable]$Parameters = @{},
[PSCredential]$Credential
)
$connString = if ($Credential) {
"Server=$ServerInstance;Database=$Database;User Id=$($Credential.UserName);Password=$($Credential.GetNetworkCredential().Password);"
} else {
"Server=$ServerInstance;Database=$Database;Integrated Security=SSPI;TrustServerCertificate=True;"
}
$conn = [System.Data.SqlClient.SqlConnection]::new($connString)
$conn.Open()
try {
$cmd = $conn.CreateCommand()
$cmd.CommandText = $Query
$cmd.CommandTimeout = 60
# Parameterised query — prevents SQL injection
foreach ($param in $Parameters.GetEnumerator()) {
$cmd.Parameters.AddWithValue("@$($param.Key)", $param.Value) | Out-Null
}
$adapter = [System.Data.SqlClient.SqlDataAdapter]::new($cmd)
$dataset = [System.Data.DataSet]::new()
$adapter.Fill($dataset) | Out-Null
return $dataset.Tables[0]
}
finally {
$conn.Close()
$conn.Dispose()
}
}
# Usage
$results = Invoke-SqlQuery -ServerInstance "SQL01\PROD" -Database "CMDB" `
-Query "SELECT ServerName, Environment, Owner FROM Servers WHERE Environment = @env" `
-Parameters @{ env = 'Production' }
$results | Format-Table
```
---
## XML, JSON & YAML Manipulation
### XML — Full Manipulation
```powershell
# Load XML
[xml]$config = Get-Content "C:\Config\app.config"
# Navigate with dot notation
$config.configuration.appSettings.add | Where-Object { $_.key -eq "DBServer" }
# XPath queries — much more powerful for complex queries
$nodes = $config.SelectNodes("//appSettings/add[@key='DBServer']")
# Modify and save
$node = $config.SelectSingleNode("//appSettings/add[@key='DBServer']")
$node.SetAttribute("value", "NEWSQL01")
$config.Save("C:\Config\app.config")
# Create XML from scratch
$xml = [System.Xml.XmlDocument]::new()
$root = $xml.CreateElement("Servers")
$xml.AppendChild($root) | Out-Null
$serverNode = $xml.CreateElement("Server")
$serverNode.SetAttribute("name", "WEB01")
$serverNode.SetAttribute("role", "Web")
$root.AppendChild($serverNode) | Out-Null
$xml.Save("C:\Config\servers.xml")
```
### JSON — Deep Manipulation
```powershell
# Parse JSON with depth (default depth is only 2 — a common gotcha)
$data = Get-Content "config.json" -Raw | ConvertFrom-Json -Depth 20
# Modify nested properties
$data.servers | Where-Object { $_.env -eq 'prod' } | ForEach-Object {
$_.maxConnections = 500
}
# Convert back — always set -Depth to avoid truncation
$data | ConvertTo-Json -Depth 20 | Set-Content "config.json"
# Compare two JSON configs
$a = Get-Content "config_old.json" -Raw | ConvertFrom-Json
$b = Get-Content "config_new.json" -Raw | ConvertFrom-Json
Compare-Object ($a | ConvertTo-Json -Depth 10 -Compress) ($b | ConvertTo-Json -Depth 10 -Compress)
```
---
## Performance Optimisation
### Measure and Profile
```powershell
# Basic timing
$time = Measure-Command { Get-ADUser -Filter * }
Write-Host "Took: $($time.TotalSeconds)s"
# Compare approaches
$methods = @{
'Pipeline filter' = { Get-Process | Where-Object { $_.CPU -gt 10 } }
'Direct filter' = { Get-Process | Where-Object CPU -gt 10 }
'Method filter' = { (Get-Process).Where({ $_.CPU -gt 10 }) }
}
$methods.GetEnumerator() | ForEach-Object {
$t = Measure-Command $_.Value
[PSCustomObject]@{ Method = $_.Key; Ms = [math]::Round($t.TotalMilliseconds, 2) }
} | Sort-Object Ms
```
### Key Optimisation Patterns
```powershell
# 1. Use .Where() and .ForEach() instead of pipeline for in-memory collections
$large = 1..100000
# Slow — pipeline overhead per element
$large | Where-Object { $_ % 2 -eq 0 } | ForEach-Object { $_ * 2 }
# Fast — method-based, no pipeline overhead
$large.Where({ $_ % 2 -eq 0 }).ForEach({ $_ * 2 })
# 2. StringBuilder for string concatenation in loops
$sb = [System.Text.StringBuilder]::new()
1..10000 | ForEach-Object { $sb.AppendLine("Line $_") | Out-Null }
$result = $sb.ToString()
# Never use $str += "..." in a loop — creates a new string object every iteration
# 3. Generic List instead of array for growing collections
# Slow — array re-allocation on every +=
$arr = @(); 1..10000 | ForEach-Object { $arr += $_ }
# Fast — List<T> amortised O(1) append
$list = [System.Collections.Generic.List[int]]::new()
1..10000 | ForEach-Object { $list.Add($_) }
# 4. Filter server-side, not client-side
# Slow — gets ALL users, filters locally
Get-ADUser -Filter * | Where-Object { $_.Department -eq "IT" }
# Fast — filter at the directory level
Get-ADUser -Filter { Department -eq "IT" }
Get-ADUser -LDAPFilter "(&(objectClass=user)(department=IT))"
# 5. Select-Object -First/Last stops the pipeline early
Get-ChildItem C:\Windows -Recurse | Select-Object -First 1 # Stops after finding first match
# 6. Use $null = or [void] to suppress pipeline output (faster than | Out-Null)
$null = $list.Add(42) # Fast
[void]$list.Add(42) # Also fast
$list.Add(42) | Out-Null # Slower — creates pipeline
```
---
## Security & Constrained Language Mode
### Check and Set Language Mode
```powershell
$ExecutionContext.SessionState.LanguageMode # FullLanguage | ConstrainedLanguage | RestrictedLanguage | NoLanguage
# What's blocked in ConstrainedLanguage mode:
# - Add-Type
# - [System.Type]::GetType()
# - New-Object for most .NET types
# - Direct .NET method calls on many types
# - Script block literals assigned to variables in certain ways
# Test if you're in constrained mode
if ($ExecutionContext.SessionState.LanguageMode -ne 'FullLanguage') {
Write-Warning "Running in $($ExecutionContext.SessionState.LanguageMode) — some operations unavailable"
}
```
### AMSI (Antimalware Scan Interface) Awareness
```powershell
# AMSI scans PowerShell script blocks before execution in PS 5.1+
# Your scripts must not trigger AV signatures — use clear, well-documented code in production
# Check if AMSI is active:
[Ref].Assembly.GetType('System.Management.Automation.AmsiUtils') | Out-Null
# If no error, AMSI is present
# Corporate environments often have AMSI providers — test your automation scripts
# against corporate AV in dev/staging before prod deployment
```
### Code Signing
```powershell
# Sign a script with a code signing certificate
$cert = Get-ChildItem Cert:\CurrentUser\My -CodeSigningCert | Select-Object -First 1
Set-AuthenticodeSignature -FilePath "C:\Scripts\Deploy.ps1" -Certificate $cert
# Verify signature
Get-AuthenticodeSignature "C:\Scripts\Deploy.ps1"
# Set execution policy to only allow signed scripts
Set-ExecutionPolicy AllSigned -Scope LocalMachine
# Verify all scripts in a directory are signed
Get-ChildItem "C:\Scripts\*.ps1" | Get-AuthenticodeSignature |
Where-Object { $_.Status -ne 'Valid' } |
Select-Object Path, Status
```
---
## Working with .NET Directly
```powershell
# Reflection — inspect .NET types at runtime
[System.AppDomain]::CurrentDomain.GetAssemblies() |
Where-Object { $_.FullName -like "*Automation*" }
# Load a .NET assembly
Add-Type -Path "C:\Libs\CustomLib.dll"
Add-Type -AssemblyName "System.Net.Http"
# Create .NET objects
$httpClient = [System.Net.Http.HttpClient]::new()
$uri = [System.Uri]::new("https://api.example.com")
# Compile C# inline — extend PowerShell with custom .NET code
Add-Type -TypeDefinition @"
using System;
using System.Runtime.InteropServices;
public class NativeMethods {
[DllImport("kernel32.dll", SetLastError = true)]
public static extern bool SetConsoleTitle(string lpConsoleTitle);
[DllImport("advapi32.dll", SetLastError = true)]
public static extern bool OpenProcessToken(IntPtr ProcessHandle, uint DesiredAccess, out IntPtr TokenHandle);
}
"@ -Language CSharp
[NativeMethods]::SetConsoleTitle("My Admin Console")
# Generic collections with type safety
$serverDict = [System.Collections.Generic.Dictionary[string, PSObject]]::new()
$serverDict['WEB01'] = [PSCustomObject]@{ CPU = 42; Mem = 8 }
# Use LINQ via reflection (PS 7 makes this cleaner)
$list = [System.Collections.Generic.List[int]]@(5, 2, 8, 1, 9, 3)
[System.Linq.Enumerable]::OrderBy($list, [Func[int,int]]{ $args[0] })
```
---
## PowerShell Providers & PSDrives
```powershell
# List all providers
Get-PSProvider
# Create custom PSDrive for any provider
New-PSDrive -Name HKCU_Run -PSProvider Registry -Root HKCU:\Software\Microsoft\Windows\CurrentVersion\Run
Get-ChildItem HKCU_Run:
New-PSDrive -Name Logs -PSProvider FileSystem -Root \\LOGSERVER\Logs$ -Credential (Get-Credential)
Get-ChildItem Logs:\
# Write a custom provider (skeleton — implement System.Management.Automation.Provider)
# Useful for exposing non-filesystem data as a navigable hierarchy (config APIs, CMDB, etc.)
# Full implementation requires inheriting from NavigationCmdletProvider or ContainerCmdletProvider
```
---
## Enterprise Automation Patterns
### Idempotent Configuration Function
```powershell
# Always safe to run multiple times — checks before acting
function Set-ServerBaseline {
[CmdletBinding(SupportsShouldProcess)]
param([string]$ComputerName)
Invoke-Command -ComputerName $ComputerName -ScriptBlock {
# Pattern: Test → Report → Change (only if needed)
$changes = [System.Collections.Generic.List[string]]::new()
# TLS 1.0 disabled?
$tls10 = Get-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server" `
-Name Enabled -ErrorAction SilentlyContinue
if ($tls10.Enabled -ne 0) {
Set-ItemProperty "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\TLS 1.0\Server" `
-Name Enabled -Value 0 -Type DWORD -Force
$changes.Add("Disabled TLS 1.0")
}
# SMBv1 disabled?
$smb1 = Get-SmbServerConfiguration | Select-Object -Expand EnableSMB1Protocol
if ($smb1) {
Set-SmbServerConfiguration -EnableSMB1Protocol $false -Force
$changes.Add("Disabled SMBv1")
}
[PSCustomObject]@{
Server = $env:COMPUTERNAME
Changes = if ($changes.Count) { $changes -join '; ' } else { 'Already compliant' }
}
}
}
```
### Configuration Data Pattern (Separation of Code and Data)
```powershell
# config\servers.psd1 — data only, no logic
@{
Production = @{
WebServers = @("WEB01","WEB02","WEB03")
DBServers = @("SQL01","SQL02")
MaxThreads = 32
LogLevel = "WARN"
}
Staging = @{
WebServers = @("WEB-STG01")
DBServers = @("SQL-STG01")
MaxThreads = 8
LogLevel = "DEBUG"
}
}
# automation\deploy.ps1 — logic only, reads data
$config = Import-PowerShellDataFile "C:\Config\servers.psd1"
$env = $config[$TargetEnvironment]
Invoke-ParallelRemote -ComputerName $env.WebServers -ScriptBlock {
# ... deployment logic ...
} -ThrottleLimit $env.MaxThreads
```
### Retry with Exponential Backoff
```powershell
function Invoke-WithRetry {
param (
[scriptblock]$ScriptBlock,
[int]$MaxRetries = 5,
[int]$InitialDelayMs = 500,
[double]$Backoff = 2.0,
[type[]]$RetryOn = @([System.Exception])
)
$attempt = 0
$delay = $InitialDelayMs
while ($true) {
try {
return & $ScriptBlock
}
catch {
$attempt++
$shouldRetry = $RetryOn | Where-Object { $_.IsAssignableFrom($_.Exception.GetType()) }
if ($attempt -ge $MaxRetries -or -not $shouldRetry) {
Write-Error "Failed after $attempt attempt(s): $_"
throw
}
Write-Warning "Attempt $attempt failed. Retrying in $($delay)ms... ($_)"
Start-Sleep -Milliseconds $delay
$delay = [int]($delay * $Backoff)
}
}
}
# Usage
Invoke-WithRetry -MaxRetries 5 -ScriptBlock {
Invoke-RestMethod "https://flaky-api.corp.com/data"
}
```
---
## Debugging & Diagnostics
### PowerShell Debugger
```powershell
# Set a breakpoint on a line
Set-PSBreakpoint -Script "C:\Scripts\Deploy.ps1" -Line 42
# Set a breakpoint on a variable change
Set-PSBreakpoint -Variable "targetServer" -Mode ReadWrite
# Set a breakpoint on a command
Set-PSBreakpoint -Command "Invoke-RestMethod"
# Conditional breakpoint
Set-PSBreakpoint -Script "C:\Scripts\Deploy.ps1" -Line 42 -Action {
if ($server -eq "PROD01") { break }
}
# List and manage breakpoints
Get-PSBreakpoint
Disable-PSBreakpoint -Id 1
Remove-PSBreakpoint -Id 1
# Trace execution — shows every line as it runs
Set-PSDebug -Trace 2 # 2 = trace + variable assignments
Get-Service
Set-PSDebug -Off
# Trace specific script block
Trace-Command -Name ParameterBinding -Expression { Get-Service -Name "W3SVC" } -PSHost
```
### Verbose & Debug Stream Control
```powershell
# Write to different streams
Write-Verbose "Detailed progress info" # Stream 4 — shown with -Verbose
Write-Debug "Debug detail" # Stream 5 — shown with -Debug
Write-Information "Structured info" -Tags "Audit" # Stream 6
# Redirect streams to file for investigation
.\script.ps1 -Verbose 4>&1 | Tee-Object -FilePath "C:\Logs\verbose.log"
# Capture all streams
.\script.ps1 *>&1 | Out-File "C:\Logs\all-streams.log"
# Redirect error stream to success stream (lets you capture errors in a variable)
$output = .\script.ps1 2>&1
$errors = $output | Where-Object { $_ -is [System.Management.Automation.ErrorRecord] }
```
---
## CI/CD Integration for PowerShell
### Pester — Unit Testing
```powershell
# Get-ServerHealth.Tests.ps1
BeforeAll {
# Load the module under test
Import-Module "$PSScriptRoot\..\MyModule.psd1" -Force
}
Describe "Get-ServerHealth" {
Context "When server is reachable" {
BeforeAll {
# Mock external dependencies — don't hit real infrastructure in tests
Mock Test-Connection { return $true }
Mock Get-CimInstance {
return [PSCustomObject]@{
FreePhysicalMemory = 4GB / 1KB
LastBootUpTime = (Get-Date).AddDays(-5)
}
}
}
It "Should return a result object" {
$result = Get-ServerHealth -ComputerName "FAKESERVER"
$result | Should -Not -BeNullOrEmpty
}
It "Should report server as online" {
$result = Get-ServerHealth -ComputerName "FAKESERVER"
$result.Online | Should -BeTrue
}
It "Should calculate uptime in days" {
$result = Get-ServerHealth -ComputerName "FAKESERVER"
$result.UptimeDays | Should -BeGreaterThan 0
}
}
Context "When server is unreachable" {
BeforeAll { Mock Test-Connection { return $false } }
It "Should return Online as false" {
$result = Get-ServerHealth -ComputerName "DEADSERVER"
$result.Online | Should -BeFalse
}
}
}
# Run tests with coverage report
$config = New-PesterConfiguration
$config.Run.Path = ".\Tests"
$config.CodeCoverage.Enabled = $true
$config.CodeCoverage.Path = ".\Public\*.ps1"
$config.Output.Verbosity = "Detailed"
Invoke-Pester -Configuration $config
```
### Azure DevOps / GitHub Actions Pipeline Task
```yaml
# azure-pipelines.yml snippet for PowerShell module CI
- task: PowerShell@2
displayName: 'Run PSScriptAnalyzer'
inputs:
targetType: inline
script: |
Install-Module PSScriptAnalyzer -Force -Scope CurrentUser
$results = Invoke-ScriptAnalyzer -Path ./MyModule -Recurse -Severity Error,Warning
if ($results) {
$results | Format-Table -AutoSize
throw "PSScriptAnalyzer found $($results.Count) issues"
}
- task: PowerShell@2
displayName: 'Run Pester Tests'
inputs:
targetType: inline
script: |
Install-Module Pester -MinimumVersion 5.0 -Force -Scope CurrentUser
$config = New-PesterConfiguration
$config.Run.Path = './Tests'
$config.TestResult.Enabled = $true
$config.TestResult.OutputPath = '$(Agent.TempDirectory)/pester.xml'
$result = Invoke-Pester -Configuration $config -PassThru
if ($result.FailedCount -gt 0) { throw "$($result.FailedCount) tests failed" }
- task: PublishTestResults@2
inputs:
testResultsFormat: NUnit
testResultsFiles: '$(Agent.TempDirectory)/pester.xml'
```
### PSScriptAnalyzer — Lint & Best Practice Enforcement
```powershell
Install-Module PSScriptAnalyzer -Scope CurrentUser
# Analyse a script or module
Invoke-ScriptAnalyzer -Path "C:\Scripts\Deploy.ps1"
Invoke-ScriptAnalyzer -Path "C:\Modules\MyModule" -Recurse
# Custom rules configuration — PSScriptAnalyzerSettings.psd1
@{
Severity = @('Error', 'Warning')
ExcludeRules = @('PSAvoidUsingWriteHost') # Suppress specific rules
IncludeRules = @(
'PSAvoidUsingPlainTextForPassword',
'PSUseShouldProcessForStateChangingFunctions',
'PSUseApprovedVerbs',
'PSAvoidGlobalVars'
)
}
# Apply custom settings
Invoke-ScriptAnalyzer -Path . -Recurse -Settings "C:\Config\PSScriptAnalyzerSettings.psd1"
# Auto-fix certain violations
Invoke-ScriptAnalyzer -Path ".\script.ps1" -Fix
```
---
## Quick Reference — Advanced Operators & Syntax
```powershell
# Null coalescing (PS 7+)
$value = $data.Setting ?? "default"
# Null conditional member access (PS 7+)
$len = $str?.Length # Returns null if $str is null, no error
# Ternary operator (PS 7+)
$label = $isProduction ? "PROD" : "NON-PROD"
# Pipeline chain operators (PS 7+)
Start-Service W3SVC && Write-Host "Started" || Write-Host "Failed"
# ForEach-Object -Parallel with timeout
$servers | ForEach-Object -Parallel { ... } -ThrottleLimit 10 -TimeoutSeconds 60
# Splatting — cleaner than long lines
$params = @{
ComputerName = "SERVER01"
ScriptBlock = { hostname }
Credential = $cred
ErrorAction = 'Stop'
}
Invoke-Command @params
# Multiple assignment
$a, $b, $c = 1, 2, 3
$first, $rest = 1, 2, 3, 4, 5 # $first = 1; $rest = 2,3,4,5
# Hash table merging (PS 7+)
$base = @{ A = 1; B = 2 }
$overlay = @{ B = 99; C = 3 }
$merged = $base + $overlay # { A=1; B=99; C=3 } — overlay wins
# Script block as first-class value
$validators = @{
IsPositive = { param($n) $n -gt 0 }
IsServer = { param($s) $s -match '^SERVER\d+$' }
}
& $validators['IsServer'] "SERVER01" # True
```
---
*Last updated: 2026 | PowerShell 5.1 / 7.x — notes indicate version-specific features*