Templates/Plugins/Invoke-PluginPostRunBeacon.ps1

# Beacon Heartbeat Plugin — IDBridge PostRun plugin template
# TemplateVersion: 1
<#
Shipped with the IDBridge module and copied to <RootPath>\Plugins by Install-IDBridge.
Set the Beacon ingest URL and the site id below, store the automation's Beacon site key
in the vault (Set-IDBridgeSecret -Name 'ApiKey-Beacon'), then enable the plugin in
IDBridgeConfig.psd1 — it throws until the placeholders are edited.

POSTs one heartbeat to Beacon (SC Networks' status dashboard) after every run, failed and
ReadOnly runs included: an envelope of sourceType 'automation' whose data carries result
(Success / Warning when a write failed / Failed when the run threw), a one-line message
built from the counts ("412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes,
0 write failures, 47s"), and the counts and flags themselves. Beacon's staleness model does
the rest: an automation source is expected every ~12 hours, so a nightly run that stops
reporting goes amber on its own and a Failed result goes red at once. Counts and flags
only, no per-user data.

The send is fire-and-forget: short timeout, failures are logged as a Warn and never affect
the run. The envelope is built by ConvertTo-BeaconHeartbeat below, a pure helper the tests
under tests\Templates\Plugins exercise.
#>



function Invoke-PluginPostRunBeacon {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [pscustomobject]$RunResult
    )

    $ingestUrl = "https://YOUR-BEACON/api/ingest"   # e.g. https://beacon.scnlabs.net/api/ingest
    $siteId    = "YOUR-SITE-ID"                      # the district's Beacon site id (Beacon Setup → Sites)
    $sourceId  = "idbridge-sync"                     # how this install shows on the dashboard; one per install

    if ($ingestUrl -eq "https://YOUR-BEACON/api/ingest" -or $siteId -eq "YOUR-SITE-ID") {
        Throw "Invoke-PluginPostRunBeacon: this plugin template still has placeholder values. Edit $($PSCommandPath) for your district before enabling it."
    }

    $envelope = ConvertTo-BeaconHeartbeat -RunResult $RunResult -SiteId $siteId -SourceId $sourceId

    try {
        $apiKey = Get-IDBridgeSecret -Name 'ApiKey-Beacon' -AsPlainText
        $response = Invoke-RestMethod -Uri $ingestUrl -Method Post -Headers @{ 'x-api-key' = $apiKey } `
            -Body ($envelope | ConvertTo-Json -Depth 5 -Compress) -ContentType 'application/json' -TimeoutSec 30
        if ($response.rejected -gt 0) {
            Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon: Beacon rejected the heartbeat (run unaffected): $($response.errors | ConvertTo-Json -Compress)" -Level Warn
        }
        else {
            Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon sent a $($envelope.data.result) heartbeat: $($envelope.data.message)" -Level Trace
        }
    }
    catch {
        Write-Log -Message "Plugin: Invoke-PluginPostRunBeacon send failed (run unaffected): $($_.Exception.GetType().Name)" -Level Warn
    }
}


# The heartbeat envelope Beacon ingests, built from the RunResult alone. Pure: no config,
# no vault, no network — which is what makes it testable without a run.
function ConvertTo-BeaconHeartbeat {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)]
        [pscustomobject]$RunResult,

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

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

    $counts = $RunResult.Counts
    $writeFailures = [int]$counts.Failed

    # Failed when the run threw (a threshold abort, a startup failure, an unhandled write
    # error); Warning when the run finished but a write did not stick; Success otherwise —
    # a ReadOnly run that computed cleanly is a Success, its mode named in the message.
    if (-not $RunResult.Success) {
        $result = 'Failed'
        $error = $RunResult.RunError
        $reason = if ($error) { "$($error.Exception.GetType().Name): $($error.Exception.Message)" } else { 'unknown error' }
        if ($reason.Length -gt 160) { $reason = $reason.Substring(0, 157) + '...' }
        $message = "run failed after $($RunResult.DurationSeconds)s -- $reason"
    }
    else {
        $result = if ($writeFailures -gt 0) { 'Warning' } else { 'Success' }
        $mode = if ($RunResult.ReadOnly) { 'ReadOnly: ' } elseif ($RunResult.TestRun) { 'test run: ' } else { '' }
        $message = "$mode$($counts.Managed) managed: $($counts.Create) created, $($counts.Update) updated, " +
            "$($counts.Deactivate) deactivated, $([int]$counts.GroupAdd + [int]$counts.GroupRemove) group changes, " +
            "$writeFailures write failures, $($RunResult.DurationSeconds)s"
    }

    return [ordered]@{
        siteId      = $SiteId
        sourceType  = 'automation'
        sourceId    = $SourceId
        collectedAt = $RunResult.RunEnd.ToUniversalTime().ToString("yyyy-MM-dd'T'HH:mm:ss'Z'")
        data        = [ordered]@{
            result          = $result
            message         = $message
            moduleVersion   = $RunResult.ModuleVersion
            durationSeconds = $RunResult.DurationSeconds
            readOnly        = [bool]$RunResult.ReadOnly
            testRun         = [bool]$RunResult.TestRun
            managed         = [int]$counts.Managed
            created         = [int]$counts.Create
            updated         = [int]$counts.Update
            deactivated     = [int]$counts.Deactivate
            groupAdds       = [int]$counts.GroupAdd
            groupRemoves    = [int]$counts.GroupRemove
            writeFailures   = $writeFailures
        }
    }
}