YOUR CAPSTONE PROJECT · LESSON 17

Capstone: build a fleet assessment tool

Combine capacity, service, and backup observations into a repeatable report and a proposed service plan.

45 min · learn at your pace

Your administration brief

Build Invoke-FleetAssessment -DataDirectory -OutputDirectory for a teammate who reviews several sites. Each supplied directory contains policy.json, servers.csv, disks.csv, services.csv, and backups.json. The exports are normalised: computer names are unique in inventory, service identities are unique per host, and each host has at most one backup record. Capacity columns are numeric. Backup Status is Succeeded or Failed; populated timestamps contain explicit offsets.

Policy supplies Site, NowUtc, MinimumFreePercent, MaxBackupAgeHours, and RequiredServices. Scope the assessment to servers whose State is Active. Do not query machines or apply service changes. The files are supplied observations; OS and remoting simulations belong to the future Advanced path.

Findings contract

Emit a finding with Computer, Rule, Resource, Value for each condition:

  • DiskLow: valid free space is strictly below the configured percentage. Resource is the mount; Value is the percentage rounded to two decimals. Compare before rounding. Exactly the threshold passes.
  • DiskUnknown: total is non-positive, free is negative, or free exceeds total. Use the mount and Value InvalidCapacity. If a host has no disk rows, use Resource * and Value Missing.
  • ServiceState: an observed required service is not Running. Resource is its name; Value is its observed State. Match by computer and service name together.
  • ServiceUnknown: a required service is absent from that host's export. Resource is the service name and Value is Missing.
  • BackupUnknown: no backup record or no LastSuccessUtc gives Value Missing; a future LastSuccessUtc on a Succeeded record gives FutureTimestamp. Resource is always backup.
  • BackupFailed: a record with a populated LastSuccessUtc has Status Failed. Value is Failed, regardless of the age of that earlier success.
  • BackupStale: a Succeeded record has a non-future LastSuccessUtc strictly more than MaxBackupAgeHours before NowUtc. Value is the unchanged timestamp string. Exactly the age limit passes.

Evaluate backup conditions in that order: missing record/history, failed status, then future or stale successful history. These rules describe the supplied export's evidence, not a guarantee that a backup can restore.

Three saved outputs

Write findings.csv with the four finding columns. Write service-plan.csv with Computer, Name, Current, Desired, Action for every service finding. Desired is Running. A Stopped service proposes Start; other non-running or missing observations propose Investigate. Use Current Missing for absent records. These are proposals only.

Write summary.json with Site, AsOfUtc, HostCount, FindingCount, UnknownCount, Coverage, Hosts. Copy Site and NowUtc from policy. Hosts is a sorted unique array of active computer names. UnknownCount counts findings whose rule ends in Unknown. Coverage is Empty when no active servers exist, Incomplete when UnknownCount is positive, and Complete otherwise. Complete means the required observations were available, not that no findings exist.

Create the output directory if needed. Empty CSV files must still contain their stated headers; empty Hosts must remain an array. Return one object with boolean Success, FindingCount, and Error. Successful processing has an empty Error even when the report contains findings. Missing required files or malformed JSON return Success false, count 0, and a non-empty diagnostic without an uncaught exception or a new successful summary. Read the required inputs before writing outputs.

Build and verify incrementally

Start with fleet-a, then try fleet-b, which changes the site, time, threshold, host identity, and results. Fleet-healthy exercises exact boundaries. Fleet-empty has no systems; fleet-gaps has a system without observations. The checker also supplies unavailable or malformed inputs and runs a successful assessment twice into the same destination. Replace reports rather than append duplicate findings, and leave all source files unchanged.

Reuse the earlier capacity calculations, service joins, time comparisons, error handling, and export patterns. Keep helpers small enough to inspect individually. A report another person can rerun and explain is the goal.

Try an example.ps1
Get-Content '/home/user/fleet-a/policy.json' -Raw | ConvertFrom-Json -DateKind String
Import-Csv '/home/user/fleet-a/servers.csv'
YOUR TURN

Put it into practice.

Implement Invoke-FleetAssessment with the finding, proposal, summary, and error contracts above. Try fleet-a into /home/user/fleet-report. The checker also uses the second site, healthy boundaries, empty inventory, missing observations, and invalid inputs. Preserve source files, derive policy from each directory, and make repeated successful runs replace their output rather than accumulate it.

See an explained solution
One way to solve it.ps1
function Invoke-FleetAssessment {
    param([string]$DataDirectory, [string]$OutputDirectory)
    try {
        $policy = Get-Content -LiteralPath (Join-Path $DataDirectory 'policy.json') -Raw -ErrorAction Stop | ConvertFrom-Json -DateKind String -ErrorAction Stop
        $servers = @(Import-Csv -LiteralPath (Join-Path $DataDirectory 'servers.csv') -ErrorAction Stop | Where-Object State -EQ Active)
        $disks = @(Import-Csv -LiteralPath (Join-Path $DataDirectory 'disks.csv') -ErrorAction Stop)
        $services = @(Import-Csv -LiteralPath (Join-Path $DataDirectory 'services.csv') -ErrorAction Stop)
        $backups = @(Get-Content -LiteralPath (Join-Path $DataDirectory 'backups.json') -Raw -ErrorAction Stop | ConvertFrom-Json -DateKind String -ErrorAction Stop)
        $now = [DateTimeOffset]::Parse($policy.NowUtc)
        $plan = @()
        $findings = @(
            foreach ($server in $servers) {
                $name = $server.Computer
                $volumes = @($disks | Where-Object Computer -EQ $name)
                if (-not $volumes.Count) { [pscustomobject]@{Computer=$name;Rule='DiskUnknown';Resource='*';Value='Missing'} }
                foreach ($disk in $volumes) {
                    $total = [double]$disk.TotalBytes; $free = [double]$disk.FreeBytes
                    if ($total -le 0 -or $free -lt 0 -or $free -gt $total) {
                        [pscustomobject]@{Computer=$name;Rule='DiskUnknown';Resource=$disk.Mount;Value='InvalidCapacity'}
                    } else {
                        $percent = 100 * $free / $total
                        if ($percent -lt $policy.MinimumFreePercent) {
                            [pscustomobject]@{Computer=$name;Rule='DiskLow';Resource=$disk.Mount;Value=[math]::Round($percent,2)}
                        }
                    }
                }
                foreach ($serviceName in $policy.RequiredServices) {
                    $service = $services | Where-Object { $_.Computer -eq $name -and $_.Name -eq $serviceName }
                    if (-not $service) {
                        [pscustomobject]@{Computer=$name;Rule='ServiceUnknown';Resource=$serviceName;Value='Missing'}
                        $plan += [pscustomobject]@{Computer=$name;Name=$serviceName;Current='Missing';Desired='Running';Action='Investigate'}
                    } elseif ($service.State -ne 'Running') {
                        [pscustomobject]@{Computer=$name;Rule='ServiceState';Resource=$serviceName;Value=$service.State}
                        $plan += [pscustomobject]@{Computer=$name;Name=$serviceName;Current=$service.State;Desired='Running';Action=$(if ($service.State -eq 'Stopped') { 'Start' } else { 'Investigate' })}
                    }
                }
                $backup = $backups | Where-Object Computer -EQ $name
                if (-not $backup -or -not $backup.LastSuccessUtc) {
                    [pscustomobject]@{Computer=$name;Rule='BackupUnknown';Resource='backup';Value='Missing'}
                } elseif ($backup.Status -eq 'Failed') {
                    [pscustomobject]@{Computer=$name;Rule='BackupFailed';Resource='backup';Value='Failed'}
                } else {
                    $last = [DateTimeOffset]::Parse($backup.LastSuccessUtc)
                    if ($last -gt $now) { [pscustomobject]@{Computer=$name;Rule='BackupUnknown';Resource='backup';Value='FutureTimestamp'} }
                    elseif (($now - $last).TotalHours -gt $policy.MaxBackupAgeHours) {
                        [pscustomobject]@{Computer=$name;Rule='BackupStale';Resource='backup';Value=$backup.LastSuccessUtc}
                    }
                }
            }
        )
        $unknown = @($findings | Where-Object Rule -Like '*Unknown').Count
        New-Item -ItemType Directory -Path $OutputDirectory -Force -ErrorAction Stop | Out-Null
        $findingPath = Join-Path $OutputDirectory 'findings.csv'
        if ($findings.Count) { $findings | Export-Csv -LiteralPath $findingPath -NoTypeInformation -ErrorAction Stop }
        else { 'Computer,Rule,Resource,Value' | Set-Content -LiteralPath $findingPath -ErrorAction Stop }
        $planPath = Join-Path $OutputDirectory 'service-plan.csv'
        if ($plan.Count) { $plan | Export-Csv -LiteralPath $planPath -NoTypeInformation -ErrorAction Stop }
        else { 'Computer,Name,Current,Desired,Action' | Set-Content -LiteralPath $planPath -ErrorAction Stop }
        [pscustomobject]@{Site=$policy.Site;AsOfUtc=$policy.NowUtc;HostCount=$servers.Count;FindingCount=$findings.Count;UnknownCount=$unknown;Coverage=$(if (-not $servers.Count) { 'Empty' } elseif ($unknown) { 'Incomplete' } else { 'Complete' });Hosts=@($servers.Computer | Sort-Object -Unique)} |
            ConvertTo-Json -Depth 5 | Set-Content -LiteralPath (Join-Path $OutputDirectory 'summary.json') -ErrorAction Stop
        [pscustomobject]@{Success=$true;FindingCount=$findings.Count;Error=''}
    }
    catch { [pscustomobject]@{Success=$false;FindingCount=0;Error=$_.Exception.Message} }
}
Invoke-FleetAssessment -DataDirectory '/home/user/fleet-a' -OutputDirectory '/home/user/fleet-report'

The active inventory defines scope, each rule uses explicit policy, and unavailable observations remain visible. Service proposals never change the input state. Successful report generation is separate from healthy systems; repeat runs replace the saved reports, while input errors produce explicit failure results.