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