Add -ComputerName support using Invoke-Command (Lesson 10) to query multiple servers at once. Combine with Send-MailMessage or an Azure Logic App webhook to email the report when warnings are detected. This is the standard SRE disk monitoring pattern before a proper monitoring stack is in place.
Lab: System Administration Scripts
Build four production-grade sysadmin scripts: a disk usage reporter, a service health checker, a log file cleanup tool, and a scheduled maintenance task registration script.
These four labs cover the core patterns used by platform and operations engineers every day. Each exercise is a complete, functional script you can adapt to real environments. They collectively exercise cmdlets from file system management, process/service management, error handling, objects/JSON, and scheduling.
🧪 Lab 1 — Disk Usage Report
Build a script that surveys all fixed drives on a machine, calculates used and free space, flags drives exceeding a configurable warning threshold, and exports results to a CSV report.
#Requires -Version 5.1
<#
.SYNOPSIS
Generates a disk usage report and flags drives over a threshold.
.PARAMETER WarningThresholdPct
Alert when a drive is more than this percentage full. Default: 80.
.PARAMETER OutputPath
CSV file output path. Default: .\disk-report.csv
.EXAMPLE
.\Get-DiskReport.ps1 -WarningThresholdPct 75 -OutputPath C:\Reports\disk.csv
#>
[CmdletBinding()]
param(
[ValidateRange(1, 99)]
[int]$WarningThresholdPct = 80,
[string]$OutputPath = ".\disk-report.csv"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$report = Get-PSDrive -PSProvider FileSystem | ForEach-Object {
$totalBytes = $_.Used + $_.Free
$usedPct = if ($totalBytes -gt 0) {
[math]::Round($_.Used / $totalBytes * 100, 1)
} else { 0 }
[PSCustomObject]@{
Drive = $_.Name
RootPath = $_.Root
TotalGB = [math]::Round($totalBytes / 1GB, 2)
UsedGB = [math]::Round($_.Used / 1GB, 2)
FreeGB = [math]::Round($_.Free / 1GB, 2)
UsedPct = $usedPct
Status = if ($usedPct -gt $WarningThresholdPct) { "WARNING" } else { "OK" }
ReportedAt = (Get-Date -Format "yyyy-MM-dd HH:mm:ss")
}
}
# Output to console
$report | Format-Table -AutoSize
# Flag warnings
$warnings = $report | Where-Object Status -eq "WARNING"
if ($warnings) {
Write-Warning "The following drives exceed ${WarningThresholdPct}% usage:"
$warnings | ForEach-Object { Write-Warning " Drive $($_.Drive): $($_.UsedPct)% used ($($_.FreeGB) GB free)" }
}
# Export CSV
$report | Export-Csv -Path $OutputPath -NoTypeInformation
Write-Host "Report saved to $OutputPath"
🧪 Lab 2 — Service Health Checker
Build a script that checks the status of a configurable list of required services, reports their states as structured objects, and exits with a non-zero code if any required service is not running (making it usable as a pipeline check gate).
#Requires -Version 5.1
<#
.SYNOPSIS
Checks health of a list of Windows services.
.PARAMETER Services
List of service names to check. Defaults to common IIS services.
.PARAMETER ExportJson
Path to export the health report as JSON.
.EXAMPLE
.\Test-ServiceHealth.ps1 -Services @("W3SVC","WAS","Spooler") -ExportJson .\health.json
#>
[CmdletBinding()]
param(
[string[]]$Services = @("W3SVC", "WAS", "wudfsvc"),
[string]$ExportJson = ""
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
$healthReport = @()
foreach ($serviceName in $Services) {
try {
$svc = Get-Service -Name $serviceName -ErrorAction Stop
$healthReport += [PSCustomObject]@{
ServiceName = $svc.Name
DisplayName = $svc.DisplayName
Status = $svc.Status.ToString()
StartType = $svc.StartType.ToString()
IsRunning = $svc.Status -eq 'Running'
CheckedAt = (Get-Date -Format "o")
}
}
catch {
Write-Warning "Service '$serviceName' not found: $_"
$healthReport += [PSCustomObject]@{
ServiceName = $serviceName
DisplayName = "UNKNOWN"
Status = "NotFound"
StartType = "N/A"
IsRunning = $false
CheckedAt = (Get-Date -Format "o")
}
}
}
$healthReport | Format-Table ServiceName, Status, IsRunning, StartType -AutoSize
if ($ExportJson) {
$healthReport | ConvertTo-Json -Depth 5 | Set-Content -Path $ExportJson -Encoding UTF8
Write-Host "Health report saved to $ExportJson"
}
# Pipeline gate: fail if any required service is not running
$notRunning = $healthReport | Where-Object IsRunning -eq $false
if ($notRunning) {
$notRunning | ForEach-Object { Write-Error "Required service NOT running: $($_.ServiceName) (Status: $($_.Status))" }
exit 1
}
Write-Host "All $($Services.Count) required services are running."
🧪 Lab 3 — Log File Cleanup
Build a script that scans a log directory for files older than a configurable age, lists what would be deleted (using -WhatIf mode first), then removes them and reports disk space recovered.
#Requires -Version 5.1
<#
.SYNOPSIS
Cleans up log files older than N days from a target directory.
.PARAMETER LogDirectory
Directory to clean. Defaults to C:\Logs.
.PARAMETER RetentionDays
Files older than this many days are deleted. Default: 30.
.PARAMETER WhatIf
If specified, shows what would be deleted without deleting.
.EXAMPLE
.\Clear-OldLogs.ps1 -LogDirectory "D:\AppLogs" -RetentionDays 14
.\Clear-OldLogs.ps1 -LogDirectory "D:\AppLogs" -WhatIf
#>
[CmdletBinding(SupportsShouldProcess, ConfirmImpact = 'High')]
param(
[string]$LogDirectory = "C:\Logs",
[ValidateRange(1, 365)]
[int]$RetentionDays = 30
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
if (-not (Test-Path -Path $LogDirectory)) {
Write-Error "Directory does not exist: $LogDirectory"
exit 1
}
$cutoffDate = (Get-Date).AddDays(-$RetentionDays)
Write-Verbose "Cutoff: files older than $($cutoffDate.ToString('yyyy-MM-dd'))"
$candidates = Get-ChildItem -Path $LogDirectory -Recurse -File |
Where-Object LastWriteTime -lt $cutoffDate
if ($candidates.Count -eq 0) {
Write-Host "No files older than $RetentionDays days found in $LogDirectory."
exit 0
}
$totalSize = ($candidates | Measure-Object -Property Length -Sum).Sum
$totalSizeMB = [math]::Round($totalSize / 1MB, 2)
Write-Host "Found $($candidates.Count) files totalling ${totalSizeMB} MB for deletion."
$deletedCount = 0
$deletedBytes = 0
foreach ($file in $candidates) {
if ($PSCmdlet.ShouldProcess($file.FullName, "Delete")) {
try {
$deletedBytes += $file.Length
Remove-Item -Path $file.FullName -Force
$deletedCount++
Write-Verbose "Deleted: $($file.FullName)"
}
catch {
Write-Warning "Failed to delete $($file.FullName): $_"
}
}
}
$recoveredMB = [math]::Round($deletedBytes / 1MB, 2)
Write-Host "Deleted $deletedCount files, recovered ${recoveredMB} MB."
Run the cleanup script with -WhatIf at least once in any new environment to confirm the file selection logic matches your expectations before files are irreversibly deleted. Set RetentionDays conservatively (90+ days) on first run until you understand the log volume patterns.
🧪 Lab 4 — Scheduled Task Registration
Register the disk report and log cleanup scripts as Windows Scheduled Tasks that run automatically without user interaction.
#Requires -RunAsAdministrator
<#
.SYNOPSIS
Registers maintenance scripts as Windows Scheduled Tasks.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
[string]$ScriptsFolder = "C:\Scripts\Maintenance"
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Register-MaintenanceTask {
param(
[string]$TaskName,
[string]$ScriptPath,
[string]$Arguments,
[string]$ScheduleDescription,
$Trigger
)
if ($PSCmdlet.ShouldProcess($TaskName, "Register Scheduled Task")) {
# Remove if already exists
if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) {
Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false
}
$action = New-ScheduledTaskAction `
-Execute "pwsh.exe" `
-Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`" $Arguments"
$settings = New-ScheduledTaskSettingsSet `
-RunOnlyIfNetworkAvailable `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Minutes 30)
$principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest
Register-ScheduledTask `
-TaskName $TaskName `
-Action $action `
-Trigger $Trigger `
-Settings $settings `
-Principal $principal `
-Force
Write-Host "Registered: $TaskName ($ScheduleDescription)"
}
}
# Daily disk report at 06:00
Register-MaintenanceTask `
-TaskName "Daily-DiskReport" `
-ScriptPath "$ScriptsFolder\Get-DiskReport.ps1" `
-Arguments "-WarningThresholdPct 80 -OutputPath C:\Reports\disk-$(Get-Date -Format yyyyMMdd).csv" `
-ScheduleDescription "Daily at 06:00" `
-Trigger (New-ScheduledTaskTrigger -Daily -At "06:00")
# Weekly log cleanup on Sundays at 02:00
Register-MaintenanceTask `
-TaskName "Weekly-LogCleanup" `
-ScriptPath "$ScriptsFolder\Clear-OldLogs.ps1" `
-Arguments "-LogDirectory D:\AppLogs -RetentionDays 30" `
-ScheduleDescription "Sundays at 02:00" `
-Trigger (New-ScheduledTaskTrigger -Weekly -DaysOfWeek Sunday -At "02:00")
Write-Host ""
Write-Host "Registered tasks:"
Get-ScheduledTask | Where-Object TaskName -in @("Daily-DiskReport","Weekly-LogCleanup") |
Select-Object TaskName, State, @{n="Schedule";e={($_.Triggers | Select-Object -First 1) -replace "MSFT_TaskTrigger"}} |
Format-Table -AutoSize
🧪 Exercises
- Run Lab 1 on your machine. Pipe the results to
Where-Object TotalGB -gt 10to show only drives with meaningful data. - Modify Lab 2 to accept a
-ComputerNamearray and wrap the service check inInvoke-Commandfor remote fleet health checking. - Create a test folder with files of different ages (use
Set-ItemPropertyto fake old timestamps) and run Lab 3 with-WhatIffirst, then without. - Add a
Send-MailMessage(or Webhook) call to Lab 1 that fires only when warnings are found. - Extend Lab 4 to also register a monthly task that runs a service health check and exports JSON.
Combine all four labs into a single Invoke-SystemHealthReport.ps1 that: collects disk usage, service health, and log file counts, builds one consolidated [PSCustomObject] per machine, exports to JSON, and if any health check fails, emits a non-zero exit code. This becomes your go-to server health script in every environment.