Capstone: A Multi-System Automation & Reporting Tool

PowerShell Intermediate/Advanced

Chapter 12 · Capstone: A Multi-System Automation & Reporting Tool

Dana's own Get-OldFileReport, from the Fundamentals capstone, checked one folder on one machine. The team has since grown to a small fleet of servers, and Dana's been asked for something that scales to all of them at once, reports out automatically, and — since other people now depend on it — is actually tested rather than just trusted. Get-FleetHealthReport is that tool, and building it touches every chapter in this course.

Step 1 — Validating Server Names Before Touching the Network

The function starts as a real advanced function (Chapter 1) — [CmdletBinding(SupportsShouldProcess)], a mandatory, pipeline-bound, non-empty -ComputerName parameter. Before any server is ever queried, each name is checked against the team's own naming convention with a regex (Chapter 2) — a malformed entry is rejected immediately, with a clear warning, rather than discovered mid-query as a confusing connection failure.

Step 2 — Querying Every Server at Once, Safely

ForEach-Object -Parallel (Chapter 4) launches a Get-CimInstance query (Chapter 6) against every validated server concurrently — and because CIM travels over the same WinRM infrastructure Chapter 3's own remoting established, no separate connection setup is needed beyond what Enable-PSRemoting already provides on each target. Results land in a ConcurrentBag, specifically avoiding Chapter 4's own isolated-runspace gotcha where a plain shared array would have silently stayed empty.

Step 3 — Building the Report: StringBuilder and a Real Excel Workbook

A quick verbose summary is assembled with StringBuilder (Chapter 5) rather than repeated string concatenation. The full report also gets written to a real Excel workbook via COM automation — with Chapter 5's own cleanup discipline applied without exception: .Quit() and Marshal.ReleaseComObject() sit inside a finally block, so no orphaned EXCEL.EXE is left behind even if writing the workbook fails partway through.

Step 4 — Reporting Out via a Webhook

The same results, converted with ConvertTo-Json, get posted to a monitoring webhook with Invoke-RestMethod (Chapter 7) — wrapped in try/catch that needs no -ErrorAction Stop, since HTTP failures are terminating by default, reading $_.ErrorDetails.Message for the real reason if the post fails.

The Complete Function

function Get-FleetHealthReport { [CmdletBinding(SupportsShouldProcess)] param( [Parameter(Mandatory, ValueFromPipeline)] [ValidateNotNullOrEmpty()] [string[]]$ComputerName, [string]$WebhookUri, [string]$ExcelPath = ".\FleetHealthReport.xlsx" ) begin { $namePattern = '^[A-Z]{2,4}-\d{3}$' $results = [System.Collections.Concurrent.ConcurrentBag[object]]::new() } process { foreach ($name in $ComputerName) { if ($name -notmatch $namePattern) { Write-Warning "Skipping '$name' — doesn't match the expected naming pattern" continue } $name | ForEach-Object -Parallel { $server = $_ $bag = $using:results try { $disk = Get-CimInstance -ClassName Win32_LogicalDisk -ComputerName $server -Filter "DeviceID='C:'" -ErrorAction Stop $svc = Get-CimInstance -ClassName Win32_Service -ComputerName $server -Filter "Name='Spooler'" -ErrorAction Stop $bag.Add([PSCustomObject]@{ ComputerName = $server FreeGB = [math]::Round($disk.FreeSpace / 1GB, 2) ServiceState = $svc.State }) } catch { $bag.Add([PSCustomObject]@{ ComputerName = $server FreeGB = $null ServiceState = "Unreachable: $($_.Exception.Message)" }) } } -ThrottleLimit 10 } } end { $sb = [System.Text.StringBuilder]::new() foreach ($r in $results) { [void]$sb.AppendLine("$($r.ComputerName): $($r.FreeGB) GB free, Spooler is $($r.ServiceState)") } Write-Verbose $sb.ToString() if ($PSCmdlet.ShouldProcess($ExcelPath, "Write Excel report")) { $excel = New-Object -ComObject Excel.Application try { $excel.Visible = $false $workbook = $excel.Workbooks.Add() $sheet = $workbook.Worksheets.Item(1) $sheet.Cells.Item(1, 1) = "ComputerName" $sheet.Cells.Item(1, 2) = "FreeGB" $sheet.Cells.Item(1, 3) = "ServiceState" $row = 2 foreach ($r in $results) { $sheet.Cells.Item($row, 1) = $r.ComputerName $sheet.Cells.Item($row, 2) = $r.FreeGB $sheet.Cells.Item($row, 3) = $r.ServiceState $row++ } $workbook.SaveAs($ExcelPath) } finally { $excel.Quit() [System.Runtime.InteropServices.Marshal]::ReleaseComObject($excel) | Out-Null } } if ($WebhookUri) { $body = $results | ConvertTo-Json -Depth 3 try { Invoke-RestMethod -Uri $WebhookUri -Method Post -Body $body -ContentType "application/json" } catch { Write-Warning "Webhook post failed: $($_.ErrorDetails.Message)" } } $results } }
Step 5 — Packaging It as a Real Module

The function moves into FleetHealth.psm1, with Export-ModuleMember and the manifest's own FunctionsToExport (Chapter 8) both restricted to just Get-FleetHealthReport — nothing internal leaks out as if it were part of the public API.

# FleetHealth.psm1 — ends with: Export-ModuleMember -Function Get-FleetHealthReport # Manifest: New-ModuleManifest -Path .\FleetHealth\FleetHealth.psd1 ` -RootModule "FleetHealth.psm1" -ModuleVersion "1.0.0" ` -FunctionsToExport @("Get-FleetHealthReport")
Step 6 — Testing the Parts That Don't Need a Real Server

A Pester test (Chapter 10) confirms the naming-pattern rejection actually works, by mocking Get-CimInstance and asserting it's never called for a malformed name — the test suite never touches a real server:

# FleetHealth.Tests.ps1 Describe "Get-FleetHealthReport" { It "never queries a name that fails the naming pattern" { Mock Get-CimInstance Get-FleetHealthReport -ComputerName "not-a-real-name" -WarningAction SilentlyContinue | Out-Null Should -Invoke Get-CimInstance -Times 0 } }
Step 7 — Making It Instantly Available

A reference to the module, plus a short convenience wrapper, goes into $PROFILE.CurrentUserAllHosts (Chapter 11) — available in every host, every session, from here on:

Import-Module FleetHealth function fleet { Get-FleetHealthReport -ComputerName (Get-Content .\servers.txt) }

Chapter Attribution

StepChapter(s) applied
1 — Validating namesChapter 1 (CmdletBinding, ValidateNotNullOrEmpty, pipeline input), Chapter 2 (regex)
2 — Querying in parallelChapter 3 (WinRM foundation CIM relies on), Chapter 4 (ForEach-Object -Parallel, ConcurrentBag), Chapter 6 (Get-CimInstance)
3 — The reportChapter 5 (StringBuilder, COM automation with proper cleanup)
4 — Webhook reportingChapter 7 (Invoke-RestMethod, terminating HTTP errors, ErrorDetails)
5 — PackagingChapter 8 (Export-ModuleMember, FunctionsToExport)
6 — TestingChapter 10 (Describe/It, Mock, Should -Invoke)
7 — AvailabilityChapter 11 ($PROFILE.CurrentUserAllHosts)
What this whole course was really about
From Chapter 1's advanced parameters to this capstone's webhook post, real object fidelity never broke down at any step — a CIM result stayed a real object across a parallel runspace boundary, survived being written into Excel, and only became text at the one deliberate point (ConvertTo-Json) where crossing an actual network genuinely required it. That's the same throughline Fundamentals 1 opened this entire two-course series with, now carrying a tool that queries a fleet, reports automatically, and — because it's tested and packaged — other people can actually trust without reading its source first.
Honest scope note
DSC (Chapter 9) is deliberately not part of this capstone — Get-FleetHealthReport reports on existing state, it doesn't enforce configuration, which is a genuinely different job DSC was built for, not an oversight. The worked example above is illustrative code, not a script run against real infrastructure in the writing of this chapter. And nothing here covers long-term monitoring-platform integration beyond the single webhook POST shown — a real alerting pipeline (thresholds, escalation, history) is its own, larger topic.

Hands-On Exercises

Exercise 1

Explain why Get-FleetHealthReport checks each computer name against a regex before the ForEach-Object -Parallel block runs, rather than letting a malformed name simply fail whatever CIM query eventually tries it.

📄 View solution
Exercise 2

Explain why the Excel-writing block's .Quit() and ReleaseComObject() calls sit inside a finally block rather than just after the try. What could happen if they didn't?

📄 View solution
Exercise 3

Explain what the Pester test in Step 6 is actually verifying, and why mocking Get-CimInstance — rather than running the function against a real server — is the right way to test that specific piece of behavior.

📄 View solution

Chapter 12 Quick Reference — Course Complete

  • 7 build steps, 11 prior chapters — one real, multi-system tool, tested and packaged, built in the order a genuine advanced automation project would actually hit each concept
  • This course's own throughline, closed out: real object fidelity preserved across parallel runspaces, remote queries, .NET automation, and a network API call — the same discipline Fundamentals 1 began, now proven at fleet scale
  • Honest scope note: no DSC (a genuinely different job), illustrative code only, no full monitoring-platform integration
  • The PowerShell Intermediate/Advanced course is now complete — 12/12 chapters