Files
TestRepo/PowerShell_How2_Adv.md
2026-03-11 11:29:14 +00:00

52 KiB

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
  2. Advanced Functions & Parameter Binding
  3. The PowerShell Pipeline — Internals
  4. Classes & Object-Oriented PowerShell
  5. Runspaces & Parallel Execution
  6. Desired State Configuration (DSC)
  7. Just Enough Administration (JEA)
  8. Secrets & Credential Management
  9. PowerShell Remoting — Advanced Patterns
  10. Logging, Auditing & Transcription
  11. Error Handling — Production Patterns
  12. Regular Expressions in PowerShell
  13. Working with APIs & REST
  14. Working with Databases
  15. XML, JSON & YAML Manipulation
  16. Performance Optimisation
  17. Security & Constrained Language Mode
  18. Working with .NET Directly
  19. PowerShell Providers & PSDrives
  20. Enterprise Automation Patterns
  21. Debugging & Diagnostics
  22. CI/CD 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

@{
    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)

# 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

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

# 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)

[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

# 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

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

# 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

# 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 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+)

# 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)

# 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

# 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

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

# Create the role capability file template
New-PSRoleCapabilityFile -Path "C:\JEA\Roles\HelpDesk.psrc"
# 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

# 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)

# 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

# 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

# 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

# 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

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

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)

# 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

# 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

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

# $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

# -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

# 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)

# 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

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

# 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

# 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

# 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

# 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

$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

# 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

# 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

# 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

# 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

# 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)

# 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

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

# 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

# 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

# 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

# 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

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

# 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