diff --git a/PowerShell_How2_Adv.md b/PowerShell_How2_Adv.md new file mode 100644 index 0000000..1e2e92c --- /dev/null +++ b/PowerShell_How2_Adv.md @@ -0,0 +1,1620 @@ +# 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 = '' + 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+(?\d+)\s+Source:\s+(?\w+)' +$Matches['id'] # "4625" +$Matches['source'] # "Security" + +# -replace with regex +"Server_PROD_WEB01" -replace '_(?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 '(?\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('(?\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 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*