This guide continues the Linux baseline hardening post. It assumes PowerShell 7+ on the control host, SSH key-based access to each Linux target, and (optionally) pwsh installed on targets for native PowerShell remoting. Root or sudo with NOPASSWD for the package commands is required on each host for anything beyond read-only reporting.

"What's our patch level?" is the question that every auditor asks and every ops team answers differently. The distros make it worse: apt reports one way, dnf another, zypper a third, and none of them emit structured output unless you work for it. The typical reactive solution is a CSV generated by hand once a quarter. The better one is a daily report.

This post builds that daily report. Each target reports its pending updates in a normalized schema, a PowerShell toolkit on the control host collects from the fleet, produces a summary + a per-host detail view, and ships alerts when the numbers cross a threshold. The apply side - rolling patches and rolling back - is the second half.

The Normalized Shape

Every per-distro collector returns the same object. This is the only interesting design decision in the post; everything else is plumbing.

# What every collector returns, per host
[pscustomobject]@{
    Host             = 'web01.corp.example.com'
    CollectedAt      = [datetime]'2026-04-25T03:12:00Z'
    Distro           = 'Ubuntu'
    Version          = '24.04'
    Kernel           = '6.8.0-35-generic'
    RebootRequired   = $false
    Updates          = @(
        [pscustomobject]@{
            Name      = 'openssl'
            Current   = '3.0.13-0ubuntu3.4'
            Candidate = '3.0.13-0ubuntu3.5'
            Security  = $true
            Source    = 'Ubuntu-Security'
        }
        # ... more packages ...
    )
    PendingCount     = 12
    SecurityCount    = 3
    OldestPendingDays = 14          # age of the oldest security update
    CollectionError  = $null        # non-null if ssh/package manager failed
}

Once every collector returns this shape, aggregation / dashboards / alerts are all a matter of $results | Where-Object SecurityCount -gt 0. No per-distro branches in the reporting code.

Per-Distro Collectors

Each collector runs on the target, inside an Invoke-Command block. It shells out to the native package manager, parses, and returns the normalized object.

Debian and Ubuntu - apt

function Get-DebianPendingUpdates
{
    <#
    .SYNOPSIS
        Returns the list of pending updates on a Debian/Ubuntu host.

    .DESCRIPTION
        Uses `apt list --upgradable` for the package list and `apt-get
        changelog` to distinguish security updates. Security is determined
        by looking at the upgrade source - Ubuntu-Security / stable-security
        components are security channels.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    process
    {
        # apt update is quiet-by-default; allow a stale index up to 24h
        $indexAgeHours = try
        {
            ((Get-Date) - (Get-Item /var/lib/apt/periodic/update-success-stamp -ErrorAction Stop).LastWriteTime).TotalHours
        } catch { [double]::PositiveInfinity }

        if ($indexAgeHours -gt 24)
        {
            $null = & sudo apt-get update -qq 2>&1
        }

        # apt list --upgradable in machine-friendly form
        # Output looks like: openssl/noble-security 3.0.13-0ubuntu3.5 amd64 [upgradable from: 3.0.13-0ubuntu3.4]
        $raw = & apt list --upgradable 2>$null
        $updates = @()

        foreach ($line in $raw)
        {
            if ($line -notmatch '^(?<name>[^/]+)/(?<pocket>\S+)\s+(?<candidate>\S+)\s+\S+\s+\[upgradable from:\s+(?<current>\S+)\]$')
            {
                continue
            }

            $updates += [pscustomobject]@{
                Name      = $matches.name
                Current   = $matches.current
                Candidate = $matches.candidate
                Source    = $matches.pocket
                Security  = $matches.pocket -match '-security'
            }
        }

        # Reboot required is a well-known touch-file on Debian-family
        $rebootRequired = Test-Path /var/run/reboot-required

        $secOnly = $updates | Where-Object { $_.Security }

        return [pscustomobject]@{
            Host              = $env:HOSTNAME
            CollectedAt       = (Get-Date).ToUniversalTime()
            Distro            = (Get-Content /etc/os-release | Where-Object { $_ -match '^NAME=' }).Split('=')[1].Trim('"')
            Version           = (Get-Content /etc/os-release | Where-Object { $_ -match '^VERSION_ID=' }).Split('=')[1].Trim('"')
            Kernel            = (uname -r)
            RebootRequired    = $rebootRequired
            Updates           = $updates
            PendingCount      = $updates.Count
            SecurityCount     = $secOnly.Count
            CollectionError   = $null
        }
    }
}

Two important details:

  • Don't force apt-get update on every run. If the index is fresh (< 24 hours), skip the refresh. apt update is slow and hammers mirrors when you're running it across 100 hosts. The stamp file /var/lib/apt/periodic/update-success-stamp is the idiomatic freshness check.
  • The "security" classification is distro-specific. On Ubuntu, any upgrade from a -security pocket (noble-security, jammy-security) counts. Debian uses stable-security. Parsing the pocket name is more reliable than trusting the apt-get changelog output, which is free-form English.

RHEL, CentOS, Alma, Rocky - dnf

function Get-RhelPendingUpdates
{
    <#
    .SYNOPSIS
        Returns the list of pending updates on an RHEL-family host.

    .DESCRIPTION
        Uses `dnf check-update` for the package list and
        `dnf updateinfo list security` for the security classification.

    .OUTPUTS
        System.Management.Automation.PSCustomObject
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    process
    {
        # check-update exits 100 when there ARE updates - this is intentional
        $raw = & dnf check-update --quiet 2>$null
        $exit = $LASTEXITCODE

        if ($exit -ne 0 -and $exit -ne 100)
        {
            throw ('dnf check-update failed with exit code {0}' -f $exit)
        }

        # Security patches: separate query
        $secRaw = & dnf updateinfo list security --quiet 2>$null
        $secNames = $secRaw |
            Where-Object { $_ -match '^(RH|ALSA|RL|FEDORA)SA-' } |
            ForEach-Object { ($_ -split '\s+')[-1].Split('.')[0] } |
            Sort-Object -Unique

        $secSet = [System.Collections.Generic.HashSet[string]]::new([string[]]$secNames)

        $updates = foreach ($line in $raw)
        {
            if ($line -notmatch '^(?<name>\S+)\.(?<arch>\S+)\s+(?<candidate>\S+)\s+(?<repo>\S+)$')
            {
                continue
            }

            $shortName = $matches.name

            [pscustomobject]@{
                Name      = $shortName
                Current   = $null           # dnf check-update doesn't emit current; see note
                Candidate = $matches.candidate
                Source    = $matches.repo
                Security  = $secSet.Contains($shortName)
            }
        }

        $rebootRequired = (& needs-restarting -r 2>&1) -match 'Reboot is required'

        return [pscustomobject]@{
            Host              = $env:HOSTNAME
            CollectedAt       = (Get-Date).ToUniversalTime()
            Distro            = (Get-Content /etc/os-release | Where-Object { $_ -match '^NAME=' }).Split('=')[1].Trim('"')
            Version           = (Get-Content /etc/os-release | Where-Object { $_ -match '^VERSION_ID=' }).Split('=')[1].Trim('"')
            Kernel            = (uname -r)
            RebootRequired    = $rebootRequired
            Updates           = $updates
            PendingCount      = @($updates).Count
            SecurityCount     = @($updates | Where-Object Security).Count
            CollectionError   = $null
        }
    }
}

dnf check-update exits 100 (not 0) when there are updates pending. That's a legacy convention and breaks every naive if ($LASTEXITCODE -ne 0) { throw } wrapper. Treat 100 and 0 as success; anything else is genuine failure.

needs-restarting -r on RHEL-family is the equivalent of /var/run/reboot-required on Debian. Ships with dnf-utils / yum-utils.

SUSE - zypper

function Get-SusePendingUpdates
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    process
    {
        # -q suppresses the progress bar; --xmlout is a bit more parseable
        # but keeping it text-based for consistency
        $raw = & zypper -q list-updates 2>$null

        $updates = foreach ($line in $raw)
        {
            if ($line -notmatch '^v\s*\|\s*(?<repo>[^\|]+?)\s*\|\s*(?<name>\S+)\s*\|\s*(?<current>\S+)\s*\|\s*(?<candidate>\S+)\s*\|\s*\S+\s*$')
            {
                continue
            }

            [pscustomobject]@{
                Name      = $matches.name.Trim()
                Current   = $matches.current.Trim()
                Candidate = $matches.candidate.Trim()
                Source    = $matches.repo.Trim()
                Security  = $matches.repo -match 'Update|Security'
            }
        }

        $rebootRequired = (& zypper ps -s 2>&1) -match 'reboot'

        return [pscustomobject]@{
            Host              = $env:HOSTNAME
            CollectedAt       = (Get-Date).ToUniversalTime()
            Distro            = (Get-Content /etc/os-release | Where-Object { $_ -match '^NAME=' }).Split('=')[1].Trim('"')
            Version           = (Get-Content /etc/os-release | Where-Object { $_ -match '^VERSION_ID=' }).Split('=')[1].Trim('"')
            Kernel            = (uname -r)
            RebootRequired    = $rebootRequired
            Updates           = $updates
            PendingCount      = @($updates).Count
            SecurityCount     = @($updates | Where-Object Security).Count
            CollectionError   = $null
        }
    }
}

Dispatch - picking the right collector per host

function Get-PendingUpdates
{
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param()

    process
    {
        $distro = ''
        if (Test-Path /etc/os-release)
        {
            $distro = (Get-Content /etc/os-release | Where-Object { $_ -match '^ID=' }).Split('=')[1].Trim('"')
        }

        switch -Regex ($distro)
        {
            '^(debian|ubuntu|linuxmint)$'      { return Get-DebianPendingUpdates }
            '^(rhel|centos|rocky|almalinux|fedora)$' { return Get-RhelPendingUpdates }
            '^(sles|opensuse.*)$'              { return Get-SusePendingUpdates }
            default
            {
                return [pscustomobject]@{
                    Host            = $env:HOSTNAME
                    CollectedAt     = (Get-Date).ToUniversalTime()
                    Distro          = $distro
                    CollectionError = ('Unsupported distro: {0}' -f $distro)
                    Updates         = @()
                    PendingCount    = 0
                    SecurityCount   = 0
                }
            }
        }
    }
}

Four distro families, one dispatch. The module bundles all four collectors and the dispatcher into one .psm1 that gets copied to each target at collection time (or pre-installed with the rest of the management tooling).

Fleet Collection

The control host runs a foreach over the target list, opens a PSSession, invokes Get-PendingUpdates remotely, and accumulates the results:

function Invoke-FleetPatchScan
{
    <#
    .SYNOPSIS
        Collects pending-update information from a list of Linux hosts.

    .DESCRIPTION
        Opens a PSSession per host, imports the PatchManagement module,
        and runs Get-PendingUpdates. One failed host does not short-circuit
        the rest; the CollectionError field on the result object tells you
        what happened.

    .PARAMETER HostName
        One or more target hostnames.

    .PARAMETER UserName
        SSH user. Must be able to sudo without password for `apt-get update`
        on Debian-family, or similar on RHEL.

    .PARAMETER KeyFilePath
        Path to the SSH private key.

    .OUTPUTS
        System.Management.Automation.PSCustomObject[]
    #>
    [CmdletBinding()]
    [OutputType([pscustomobject[]])]
    param
    (
        [Parameter(Mandatory = $true, ValueFromPipeline = $true)]
        [string[]]
        $HostName,

        [Parameter(Mandatory = $true)]
        [string]
        $UserName,

        [Parameter(Mandatory = $true)]
        [string]
        $KeyFilePath
    )

    process
    {
        $results = New-Object -TypeName 'System.Collections.Generic.List[object]'

        foreach ($h in $HostName)
        {
            $session = $null
            try
            {
                $sessionParams = @{
                    HostName    = $h
                    UserName    = $UserName
                    KeyFilePath = $KeyFilePath
                    ErrorAction = 'Stop'
                }
                $session = New-PSSession @sessionParams

                # Push the collector module to the target, then invoke
                Copy-Item -Path './PatchManagement.psm1' -Destination '/tmp/PatchManagement.psm1' -ToSession $session -Force

                $r = Invoke-Command -Session $session -ScriptBlock {
                    Import-Module /tmp/PatchManagement.psm1 -Force
                    Get-PendingUpdates
                }

                # PSSession strips custom property types; re-cast
                $r.Host = $h     # prefer the resolvable name the caller used
                $results.Add($r)
            }
            catch
            {
                $results.Add([pscustomobject]@{
                    Host            = $h
                    CollectedAt     = (Get-Date).ToUniversalTime()
                    CollectionError = $_.Exception.Message
                    Updates         = @()
                    PendingCount    = 0
                    SecurityCount   = 0
                    RebootRequired  = $false
                })
            }
            finally
            {
                if ($session) { Remove-PSSession $session }
            }
        }

        return $results.ToArray()
    }
}

One failure = one entry in the output with CollectionError populated. The other 39 hosts still produce real data. The caller decides whether a missed collection is an alert.

For fleets over ~50 hosts, parallelize with Start-ThreadJob or ForEach-Object -Parallel; see the parallelism post for the tradeoffs. The collection itself is I/O-bound (mostly waiting for SSH handshakes), so parallelism wins cleanly.

Reporting

The normalized output makes summary reports trivial.

Summary by host

$results = Invoke-FleetPatchScan -HostName $fleet -UserName 'ricardo' -KeyFilePath ~/.ssh/id_ed25519

$results | Sort-Object SecurityCount -Descending |
    Format-Table Host, Distro, Version, Kernel,
                 @{ N = 'Security'; E = { $_.SecurityCount } },
                 @{ N = 'All';      E = { $_.PendingCount } },
                 @{ N = 'Reboot';   E = { if ($_.RebootRequired) { 'YES' } else { '' } } },
                 @{ N = 'Error';    E = { $_.CollectionError } }

Produces something like:

Host                  Distro Version Kernel           Security All Reboot Error
----                  ------ ------- ------           -------- --- ------ -----
web03.corp.example... Ubuntu 24.04   6.8.0-35-generic        7  42 YES
api01.corp.example... RHEL   9.4     5.14.0-427.el9          3  15
web01.corp.example... Ubuntu 24.04   6.8.0-35-generic        2  12
...

Security-only summary

$results | Where-Object SecurityCount -gt 0 | ForEach-Object {
    $_.Updates |
        Where-Object Security |
        Select-Object @{ N = 'Host'; E = { $h = $_; $results | Where-Object Updates -contains $h | Select-Object -ExpandProperty Host } },
                      Name, Current, Candidate, Source
}

Or more simply:

$results | ForEach-Object {
    $h = $_.Host
    $_.Updates | Where-Object Security | ForEach-Object {
        [pscustomobject]@{
            Host      = $h
            Package   = $_.Name
            Current   = $_.Current
            Candidate = $_.Candidate
            Source    = $_.Source
        }
    }
} | Export-Csv ./security-updates-$(Get-Date -Format 'yyyy-MM-dd').csv -NoTypeInformation
$results | ConvertTo-Json -Depth 6 |
    Set-Content ./reports/patch-report-$(Get-Date -Format 'yyyy-MM-dd').json

Committed daily to a reports/ branch in git, the JSON gives you a diffable, auditable trend of patch debt over time. Plot SecurityCount across hosts over the last 90 days and you have a compliance graph - the kind of chart that makes a regulator go away.

Markdown dashboard

$md = @()
$md += '# Patch Level Report'
$md += ''
$md += '**As of:** {0}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm zzz')
$md += ''
$md += '| Host | Distro | Security | Pending | Reboot | Oldest |'
$md += '|------|--------|---------:|--------:|:------:|-------:|'

foreach ($r in ($results | Sort-Object SecurityCount -Descending))
{
    $reboot = if ($r.RebootRequired) { 'YES' } else { '' }
    $oldest = if ($r.OldestPendingDays) { "$($r.OldestPendingDays)d" } else { '-' }
    $md += '| {0} | {1} {2} | {3} | {4} | {5} | {6} |' -f $r.Host, $r.Distro, $r.Version, $r.SecurityCount, $r.PendingCount, $reboot, $oldest
}

$md -join "`n" | Set-Content ./reports/latest.md

Commit + push + open in any git frontend = a readable dashboard that every engineer on the team can link to.

GELF to Graylog

Pairs with the SIEM stack post:

foreach ($r in $results)
{
    $gelf = @{
        version       = '1.1'
        host          = $r.Host
        short_message = ('patch-scan pending={0} security={1} reboot={2}' -f $r.PendingCount, $r.SecurityCount, $r.RebootRequired)
        _distro       = $r.Distro
        _version      = $r.Version
        _kernel       = $r.Kernel
        _pending      = $r.PendingCount
        _security     = $r.SecurityCount
        _reboot       = [int][bool]$r.RebootRequired
    }

    $bytes = [System.Text.Encoding]::UTF8.GetBytes(($gelf | ConvertTo-Json -Compress))
    $udp   = New-Object -TypeName 'System.Net.Sockets.UdpClient'
    $null  = $udp.Send($bytes, $bytes.Length, 'graylog.corp.example.com', 12201)
    $udp.Dispose()
}

Stream daily; create a Graylog alert on _security:>5 or _reboot:1 AND _security:>0 for "this host needs a reboot and it's overdue."

Scheduled Reporting + Alert Thresholds

Drop the whole thing into a scheduled task / cron / systemd timer on the control host:

$thresholds = @{
    SecurityCountPerHost    = 5
    SecurityCountFleetTotal = 25
    RebootRequiredCount     = 3
    OldestPendingDays       = 30
}

$results = Invoke-FleetPatchScan -HostName $fleet -UserName 'ricardo' -KeyFilePath ~/.ssh/id_ed25519

$alerts = @()

$fleetSec = ($results | Measure-Object -Property SecurityCount -Sum).Sum
if ($fleetSec -gt $thresholds.SecurityCountFleetTotal)
{
    $alerts += ('Fleet-wide: {0} security patches pending across {1} hosts' -f $fleetSec, $results.Count)
}

foreach ($r in $results)
{
    if ($r.SecurityCount -gt $thresholds.SecurityCountPerHost)
    {
        $alerts += ('{0}: {1} security patches pending' -f $r.Host, $r.SecurityCount)
    }
    if ($r.RebootRequired)
    {
        $alerts += ('{0}: reboot required' -f $r.Host)
    }
}

if ($alerts)
{
    $alerts -join "`n" | Send-Alert -Channel '#patching'
    exit 1
}
exit 0

Schedule this nightly. Non-zero exit = there's a ticket for somebody tomorrow. The thresholds are a YAML sidecar you tune over time, not hardcoded constants.

Applying Patches - The Safety Net

Reporting is the easy half. Applying patches across a fleet is where "works on my laptop" meets "a kernel update on 30 hosts at once bricked auth because PAM changed." Three discipline points:

  1. Snapshot before. On VMs with LVM or ZFS, take a filesystem snapshot. On cloud providers, take an EBS/disk snapshot. The snapshot is the rollback; trust it more than the distro's package-manager rollback feature.
  2. Apply in waves. Never patch the whole fleet at once. Break into waves (canary → 20% → 50% → 100%) with a pause-and-verify between each.
  3. Health-check after. If the application isn't healthy (response code, SLO metric) after patching, stop the wave.

A minimal apply helper:

function Invoke-LinuxPatchApply
{
    [CmdletBinding(SupportsShouldProcess = $true)]
    [OutputType([pscustomobject])]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.Runspaces.PSSession]
        $Session,

        [Parameter()]
        [switch]
        $SecurityOnly,

        [Parameter()]
        [switch]
        $Snapshot
    )

    process
    {
        # 1. Snapshot
        if ($Snapshot)
        {
            $snapResult = Invoke-Command -Session $Session -ScriptBlock {
                # LVM thin-pool volume snapshot example - adjust to your env
                $when = Get-Date -Format 'yyyyMMdd-HHmmss'
                $out = & sudo lvcreate --snapshot --name ('pre-patch-{0}' -f $when) --size 2G /dev/vg0/root 2>&1
                @{ Ok = ($LASTEXITCODE -eq 0); Output = $out; SnapshotName = ('pre-patch-{0}' -f $when) }
            }
            if (-not $snapResult.Ok) { throw ('snapshot failed: {0}' -f $snapResult.Output) }
        }

        if (-not $PSCmdlet.ShouldProcess($Session.ComputerName, 'Apply patches'))
        {
            return [pscustomobject]@{ Host = $Session.ComputerName; Applied = $false; Reason = 'WhatIf' }
        }

        # 2. Apply
        $applyResult = Invoke-Command -Session $Session -ScriptBlock {
            param($securityOnly)

            $distroId = (Get-Content /etc/os-release | Where-Object { $_ -match '^ID=' }).Split('=')[1].Trim('"')

            $cmd = switch -Regex ($distroId)
            {
                '^(debian|ubuntu|linuxmint)$'
                {
                    if ($securityOnly)
                    {
                        # unattended-upgrades with the Origins-Pattern matching *-security
                        @('sudo', 'DEBIAN_FRONTEND=noninteractive', 'unattended-upgrade', '--dry-run', '-d')
                        # For real apply, drop --dry-run; for demo clarity keeping it
                    }
                    else
                    {
                        @('sudo', 'DEBIAN_FRONTEND=noninteractive', 'apt-get', '-y', 'upgrade')
                    }
                }
                '^(rhel|centos|rocky|almalinux|fedora)$'
                {
                    if ($securityOnly) { @('sudo', 'dnf', '-y', 'update', '--security') }
                    else               { @('sudo', 'dnf', '-y', 'update') }
                }
                '^(sles|opensuse.*)$'
                {
                    if ($securityOnly) { @('sudo', 'zypper', '--non-interactive', 'patch', '--category', 'security') }
                    else               { @('sudo', 'zypper', '--non-interactive', 'update') }
                }
            }

            $out  = & $cmd[0] $cmd[1..($cmd.Count - 1)] 2>&1
            @{ Ok = ($LASTEXITCODE -eq 0); Output = $out }
        } -ArgumentList $SecurityOnly.IsPresent

        [pscustomobject]@{
            Host    = $Session.ComputerName
            Applied = $applyResult.Ok
            Reason  = if ($applyResult.Ok) { 'success' } else { ($applyResult.Output -join ' | ').Substring(0, [Math]::Min(200, ($applyResult.Output -join ' | ').Length)) }
        }
    }
}

For Debian, real unattended-security-only is a standalone topic (unattended-upgrades package with properly-configured 50unattended-upgrades); the above is the dispatching shape, not a drop-in production script.

Wave-based rollout

$waves = @(
    @{ Name = 'canary';    Hosts = $fleet | Select-Object -First 1 }
    @{ Name = '20pct';     Hosts = $fleet | Select-Object -First ([Math]::Ceiling($fleet.Count * 0.2)) }
    @{ Name = '50pct';     Hosts = $fleet | Select-Object -First ([Math]::Ceiling($fleet.Count * 0.5)) }
    @{ Name = 'remainder'; Hosts = $fleet }
)

foreach ($wave in $waves)
{
    Write-Host ('=== wave {0} ({1} hosts) ===' -f $wave.Name, $wave.Hosts.Count)

    $waveResults = Invoke-FleetPatchApply -HostName $wave.Hosts `
                        -UserName 'ricardo' -KeyFilePath ~/.ssh/id_ed25519 `
                        -SecurityOnly -Snapshot

    $failed = $waveResults | Where-Object { -not $_.Applied }
    if ($failed)
    {
        Write-Host ('Wave {0} had {1} failures - aborting' -f $wave.Name, $failed.Count)
        $failed | Format-Table Host, Reason
        exit 1
    }

    # Gate: health check before the next wave
    Start-Sleep -Seconds 120
    $health = Test-FleetHealth -HostName $wave.Hosts    # your own check — HTTP probe, SLO metric, etc.
    if (-not $health.AllOk)
    {
        Write-Host ('Wave {0} passed apply but failed health check - aborting' -f $wave.Name)
        $health.Unhealthy | Format-Table
        exit 1
    }

    Write-Host ('Wave {0} applied and healthy.' -f $wave.Name)
}

One-hour-per-wave schedule gives the alerting enough time to fire if the patch is going to break something. The canary always runs first; if the canary fails, the rest of the fleet never gets touched.

Rollback

If the post-apply health check fails, the snapshot is the escape hatch:

function Invoke-LinuxPatchRollback
{
    [CmdletBinding(SupportsShouldProcess = $true)]
    param
    (
        [Parameter(Mandatory = $true)]
        [System.Management.Automation.Runspaces.PSSession]
        $Session,

        [Parameter(Mandatory = $true)]
        [string]
        $SnapshotName
    )

    process
    {
        if (-not $PSCmdlet.ShouldProcess($Session.ComputerName, ('Rollback to {0}' -f $SnapshotName)))
        {
            return
        }

        Invoke-Command -Session $Session -ScriptBlock {
            param($snap)
            # LVM merge rollback — requires reboot to complete
            $null = & sudo lvconvert --merge ('/dev/vg0/{0}' -f $snap)
            $null = & sudo systemctl reboot
        } -ArgumentList $SnapshotName
    }
}

Kernel-level changes need a reboot to finalize. Application-level rollback can often skip the reboot by re-installing the old package versions from the distro cache, but that's a gotcha-laden path; snapshots are more predictable.

CI Wiring

Same pattern as the hardening post and the AD-as-code series:

# .gitea/workflows/patch-report.yml
name: patch-report
on:
  schedule:
    - cron: '0 4 * * *'    # nightly report

jobs:
  report:
    runs-on: [ self-hosted, linux ]
    steps:
      - uses: actions/checkout@v4
      - shell: pwsh
        run: |
          Import-Module ./src/PatchManagement.psm1 -Force
          $results = Invoke-FleetPatchScan -HostName (Get-Content ./fleet.txt) `
                         -UserName 'svc-patching' -KeyFilePath $env:SSH_KEY
          $results | ConvertTo-Json -Depth 6 | Set-Content ./reports/latest.json

          # Alert thresholds
          ./scripts/Alert-On-Thresholds.ps1 -Report $results

Daily snapshot, committed back to the reports/ branch, with an alert script that writes to Slack / Teams / whatever when something exceeds the threshold.

Gotchas

  • apt update hits mirrors. Running the control script in parallel across a fleet of 50 all hitting archive.ubuntu.com at once is rude and sometimes rate-limited. Run a local apt mirror (apt-mirror, squid-deb-proxy, or apt-cacher-ng) if your fleet is big enough for this to be a real problem.
  • dnf check-update exit code 100. Already called out above; bears repeating because it bites every first-time wrapper author.
  • needs-restarting -r may not be installed. It's in dnf-utils on RHEL 9+ and in yum-utils on 7/8. The collector should degrade gracefully: if the command isn't present, return RebootRequired = $null (unknown) rather than $false.
  • Security classification is distro-specific. Ubuntu's -security pocket, Debian's -security, RHEL's updateinfo list security, SUSE's patch --category security — four different mechanisms, all mapped into the same .Security boolean by the collectors. Test each carefully per distro; false-negative security classifications are the worst kind.
  • pwsh on minimal distros. Some minimal RHEL/Alma images don't ship pwsh; installing it on hundreds of hosts for "reporting" is a lot of weight. For those, use the plain-SSH fallback and run a bash version of the collector that emits JSON the PowerShell side parses.
  • Snapshot rollback isn't free. An LVM snapshot uses extents from the pool; if your pool is 95% full, a snapshot will fail or thinly-provisioned writes will seize. Always confirm pool capacity before snapshotting.
  • Apply windows. Patching a production database at 2 PM on a Tuesday is bad judgement even if the script is perfect. Schedule patch apply windows; reporting can run whenever.
  • Reboot coordination. Kernel patches are not applied until reboot. If your dashboard shows SecurityCount: 0 but the kernel was patched yesterday and nobody rebooted, the host is still vulnerable. Track RebootRequired as its own severity, not a footnote.

Final Notes

Patch reporting is one of those things that's either free (after a day of scripting) or expensive forever (manual spreadsheet every quarter). The normalized per-distro collector + fleet dispatcher + scheduled report is maybe 400 lines total, pays for itself on the first audit, and scales from 5 hosts to 500 without rewriting.

Together with the hardening post, you now have two concrete pieces of a Linux operational toolkit in PowerShell: Day-1 hardening of a fresh install, and Day-2 patch reporting across the whole fleet. Next up, the Active Directory automation series applies the same idempotent-reporter-then-apply pattern to a very different estate.