diff --git a/CHANGELOG.md b/CHANGELOG.md index 51be400..93d8510 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,21 @@ All notable changes to IDBridge are documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). Versions use a calendar scheme `YY.M.D.build` (see [CONTRIBUTING.md](CONTRIBUTING.md#versioning--releases)). +## [26.9.9.0] - 2026-09-09 + +### Added +- **`Invoke-PluginPostRunBeacon` — a Beacon heartbeat PostRun plugin template.** POSTs one + `automation` envelope to SC Networks' Beacon dashboard after every run (failed and + ReadOnly runs included): `result` Success / Warning (a write did not stick) / Failed (the + run threw), a one-line message built from the counts, and the counts, flags, duration and + module version — no per-user data. Beacon's staleness model turns a nightly sync that + stops reporting amber on its own. Placeholders for the ingest URL and the district's + Beacon site id (throws until edited); the site key lives in the vault as + `ApiKey-Beacon`. Fire-and-forget like the webhook template: a send failure or a + rejection logs a `Warn` and never affects the run. The envelope builder is a pure helper + in the same file with tests under `tests\Templates\Plugins`. The config template gains + its descriptor (disabled) and moves to `TemplateVersion: 2`. + ## [26.9.1.3] - 2026-09-01 ### Changed diff --git a/docs/configuration.md b/docs/configuration.md index 961479b..4b82305 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -112,7 +112,7 @@ unrecognized `Tier` value fails safe to `Off`. `-DisableTelemetry` silences a si Array of plugin descriptors. `Source`/`Override` entries are executed in order by `Invoke-SourcePlugins` at the start of the run; `PostRun` entries by `Invoke-PostRunPlugins` at the end of the run (in the `finally` block, after telemetry — they fire on failed and -ReadOnly runs too). The shipped config template lists all eight descriptors with +ReadOnly runs too). The shipped config template lists all nine descriptors with `Enabled = $false`; a configured site looks like: ```powershell @@ -124,6 +124,7 @@ ReadOnly runs too). The shipped config template lists all eight descriptors with @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunWebhook' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunExport' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunOrphanReport' } +@{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunBeacon' } ``` | Key | Effect | diff --git a/docs/getting-started.md b/docs/getting-started.md index b314521..e353670 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -215,11 +215,12 @@ Flip one brake at a time, with a read-only-style review (`-ReadOnly` or `-Previe OneRoster plugins in [plugins.md](plugins.md#invoke-pluginskywardsmsstudents--source-disabled-in-config) are worked examples). -- **Run reports, CSV exports, alerting:** the four PostRun templates consume each run's +- **Run reports, CSV exports, alerting:** the five PostRun templates consume each run's results — `Invoke-PluginPostRunReport` (JSON run summary), `Invoke-PluginPostRunExport` (user-list CSVs), and `Invoke-PluginPostRunOrphanReport` (enabled accounts no source feeds anymore) work as-is once their descriptors are enabled; - `Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint. + `Invoke-PluginPostRunWebhook` POSTs a compact summary to your own endpoint, and + `Invoke-PluginPostRunBeacon` sends a heartbeat to SC Networks' Beacon dashboard. See [plugins.md](plugins.md#the-postrun-contract-invoke-postrunplugins). - **Run-history dashboard:** `Telemetry.Tier = 'Enhanced'`, then claim your SiteID (`Get-IDBridgeSiteID`) at [IDBridge Pulse](https://pulse.scnlabs.net). diff --git a/docs/plugins.md b/docs/plugins.md index 00a2041..f1c9944 100644 --- a/docs/plugins.md +++ b/docs/plugins.md @@ -288,6 +288,23 @@ throws until the placeholder URL is edited. Fire-and-forget: 10 s timeout, send a `Warn` and never affect the run. The starting point for shipping your own telemetry or alerting ("tell me when the run breaks"). +### `Invoke-PluginPostRunBeacon` — PostRun *(disabled in config)* +File: `C:\IDBridge\Plugins\Invoke-PluginPostRunBeacon.ps1`. POSTs one heartbeat envelope to +[Beacon](https://github.com/SCNetworksLLC/Beacon), SC Networks' status dashboard, after every +run — failed and ReadOnly runs included: `sourceType` `automation`, `sourceId` +`idbridge-sync`, and a `data` block of `result` (`Success`; `Warning` when a write did not +stick; `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", +prefixed `ReadOnly:` or `test run:` in those modes), and the counts, flags, duration and +module version themselves. No per-user data. Beacon's staleness model does the rest: an +automation source is expected every ~12 hours, so a nightly sync that stops reporting goes +amber on its own and a `Failed` result goes red at once. Edit the ingest URL and the +district's Beacon site id in the file (it throws until you do), store the site key Beacon +minted for this automation with `Set-IDBridgeSecret -Name 'ApiKey-Beacon'`, then enable the +descriptor. Fire-and-forget: 30 s timeout, a rejection or a send failure logs a `Warn` and +never affects the run. The envelope builder (`ConvertTo-BeaconHeartbeat`, a helper in the +same file) is pure and tested under `tests\Templates\Plugins`. + ### `Invoke-PluginPostRunExport` — PostRun *(works as-is)* File: `C:\IDBridge\Plugins\Invoke-PluginPostRunExport.ps1`. Writes the user list CSVs defined in its `$exportFiles` map (file name → the `PersonTypeID`s it carries; one file diff --git a/src/IDBridge/IDBridge.psd1 b/src/IDBridge/IDBridge.psd1 index 03cb3c4..6c64696 100644 --- a/src/IDBridge/IDBridge.psd1 +++ b/src/IDBridge/IDBridge.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'IDBridge.psm1' - ModuleVersion = '26.9.1.3' + ModuleVersion = '26.9.9.0' GUID = 'a0a0c664-888e-44e2-9c12-fc8647a520c0' Author = 'Sam Cattanach' CompanyName = 'SC Networks LLC' diff --git a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 index 2e48844..3f1fd39 100644 --- a/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 +++ b/src/IDBridge/Templates/Config/IDBridgeConfig.psd1 @@ -1,5 +1,5 @@ # IDBridgeConfig.psd1 — generated by Install-IDBridge -# TemplateVersion: 1 +# TemplateVersion: 2 # Every feature is disabled. Replace the placeholder values, then enable features one at a time. @{ @@ -93,5 +93,6 @@ @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunWebhook' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunExport' } @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunOrphanReport' } + @{ Enabled = $false; Type = "PostRun"; Function = 'Invoke-PluginPostRunBeacon' } ) } diff --git a/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 b/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 new file mode 100644 index 0000000..23aa8f9 --- /dev/null +++ b/src/IDBridge/Templates/Plugins/Invoke-PluginPostRunBeacon.ps1 @@ -0,0 +1,115 @@ +# Beacon Heartbeat Plugin — IDBridge PostRun plugin template +# TemplateVersion: 1 +<# +Shipped with the IDBridge module and copied to \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 + } + } +} diff --git a/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 b/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 new file mode 100644 index 0000000..69a342b --- /dev/null +++ b/tests/Templates/Plugins/Invoke-PluginPostRunBeacon.Tests.ps1 @@ -0,0 +1,144 @@ +<# +.SYNOPSIS +Unit tests for the shipped Invoke-PluginPostRunBeacon template: the heartbeat envelope and the send. + +.DESCRIPTION +The template is dot-sourced from src\IDBridge\Templates\Plugins the way Invoke-PostRunPlugins +dot-sources an installed copy. ConvertTo-BeaconHeartbeat is pure and gets the result/message +cases; the send is exercised with Get-IDBridgeSecret, Invoke-RestMethod and Write-Log mocked +(the template's own copy keeps its placeholders, so the plugin entry point is tested for the +throw those placeholders promise). +#> + +BeforeAll { + Import-Module (Join-Path $PSScriptRoot '..' '..' 'TestHelper.psm1') -Force + Import-IDBridgeForTest + . (Join-Path (Split-Path (Get-IDBridgeManifestPath) -Parent) 'Templates' 'Plugins' 'Invoke-PluginPostRunBeacon.ps1') + + function New-TestRunResult { + param ( + [bool]$Success = $true, + $RunError = $null, + [bool]$ReadOnly = $false, + [bool]$TestRun = $false, + [int]$Failed = 0 + ) + [PSCustomObject]@{ + SchemaVersion = 1 + ModuleVersion = '26.9.1.3' + Success = $Success + RunError = $RunError + RunStart = [DateTime]::new(2026, 9, 9, 3, 0, 0, [DateTimeKind]::Utc) + RunEnd = [DateTime]::new(2026, 9, 9, 3, 0, 47, [DateTimeKind]::Utc) + DurationSeconds = 47 + ReadOnly = $ReadOnly + TestRun = $TestRun + Counts = [PSCustomObject]@{ + Managed = 412; Create = 3; Update = 5; Deactivate = 1; GroupAdd = 2; GroupRemove = 0; Failed = $Failed + } + } + } +} + +Describe 'ConvertTo-BeaconHeartbeat' { + It 'builds a Success envelope with the counts in the message and the data' { + $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult) -SiteId 'colby' -SourceId 'idbridge-sync' + + $envelope.siteId | Should -Be 'colby' + $envelope.sourceType | Should -Be 'automation' + $envelope.sourceId | Should -Be 'idbridge-sync' + $envelope.collectedAt | Should -Be '2026-09-09T03:00:47Z' + $envelope.data.result | Should -Be 'Success' + $envelope.data.message | Should -Be '412 managed: 3 created, 5 updated, 1 deactivated, 2 group changes, 0 write failures, 47s' + $envelope.data.managed | Should -Be 412 + $envelope.data.created | Should -Be 3 + $envelope.data.groupAdds | Should -Be 2 + $envelope.data.writeFailures | Should -Be 0 + $envelope.data.moduleVersion | Should -Be '26.9.1.3' + $envelope.data.readOnly | Should -BeFalse + } + + It 'is a Warning when a write did not stick' { + $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -Failed 2) -SiteId 'colby' -SourceId 'idbridge-sync' + $envelope.data.result | Should -Be 'Warning' + $envelope.data.message | Should -BeLike '*2 write failures, 47s' + $envelope.data.writeFailures | Should -Be 2 + } + + It 'is Failed with the error class and message when the run threw' { + $runError = $null + try { throw [System.InvalidOperationException]::new('Change threshold exceeded: AD 31% (limit 25%).') } catch { $runError = $_ } + $envelope = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -Success $false -RunError $runError) -SiteId 'colby' -SourceId 'idbridge-sync' + $envelope.data.result | Should -Be 'Failed' + $envelope.data.message | Should -Be 'run failed after 47s -- InvalidOperationException: Change threshold exceeded: AD 31% (limit 25%).' + } + + It 'names the mode of a ReadOnly or test run in the message, still a Success' { + (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -ReadOnly $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.message | + Should -BeLike 'ReadOnly: 412 managed:*' + (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -TestRun $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.message | + Should -BeLike 'test run: 412 managed:*' + (ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult -ReadOnly $true) -SiteId 'colby' -SourceId 'idbridge-sync').data.result | + Should -Be 'Success' + } + + It 'serializes to the envelope Beacon ingests -- no per-user data, counts only' { + $json = ConvertTo-BeaconHeartbeat -RunResult (New-TestRunResult) -SiteId 'colby' -SourceId 'idbridge-sync' | ConvertTo-Json -Depth 5 -Compress + $json | Should -BeLike '{"siteId":"colby","sourceType":"automation","sourceId":"idbridge-sync","collectedAt":"2026-09-09T03:00:47Z","data":{"result":"Success","message":"412 managed:*' + $json | Should -Not -BeLike '*SourceData*' + $json | Should -Not -BeLike '*Applied*' + } +} + +Describe 'Invoke-PluginPostRunBeacon' { + It 'throws while the template still carries its placeholders' { + { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Throw '*placeholder values*' + } + + Context 'with the placeholders edited' { + BeforeAll { + # An edited copy, as a district's Plugins folder holds it. + $template = Get-Content -Raw (Join-Path (Split-Path (Get-IDBridgeManifestPath) -Parent) 'Templates' 'Plugins' 'Invoke-PluginPostRunBeacon.ps1') + $edited = $template.Replace('"https://YOUR-BEACON/api/ingest" #', '"https://beacon.test/api/ingest" #').Replace('"YOUR-SITE-ID" #', '"colby" #') + $editedPath = Join-Path $TestDrive 'Invoke-PluginPostRunBeacon.ps1' + Set-Content -Path $editedPath -Value $edited + . $editedPath + } + + BeforeEach { + Mock Write-Log {} + Mock Get-IDBridgeSecret { 'bcn_test' } + } + + It 'POSTs the envelope to the ingest URL with the vault key and logs the send' { + Mock Invoke-RestMethod { [pscustomobject]@{ accepted = 1; rejected = 0 } } + Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) + Should -Invoke Get-IDBridgeSecret -Times 1 -Exactly -ParameterFilter { $Name -eq 'ApiKey-Beacon' -and $AsPlainText } + Should -Invoke Invoke-RestMethod -Times 1 -Exactly -ParameterFilter { + $Uri -eq 'https://beacon.test/api/ingest' -and $Method -eq 'Post' -and $Headers['x-api-key'] -eq 'bcn_test' -and + $ContentType -eq 'application/json' -and $TimeoutSec -eq 30 -and $Body -like '{"siteId":"colby","sourceType":"automation","sourceId":"idbridge-sync",*' + } + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Trace' -and $Message -like '*sent a Success heartbeat: 412 managed:*' } + } + + It 'logs a Warn and does not throw when Beacon rejects the heartbeat' { + Mock Invoke-RestMethod { [pscustomobject]@{ accepted = 0; rejected = 1; errors = @([pscustomobject]@{ index = 0; error = 'siteId unknown' }) } } + { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*rejected the heartbeat*siteId unknown*' } + } + + It 'logs a Warn and does not throw when the send fails -- the run is unaffected' { + Mock Invoke-RestMethod { throw [System.Net.WebException]::new('The remote name could not be resolved') } + { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*send failed (run unaffected): WebException' } + } + + It 'logs a Warn and does not throw when the vault has no key' { + Mock Get-IDBridgeSecret { throw [System.IO.FileNotFoundException]::new('ApiKey-Beacon') } + Mock Invoke-RestMethod { throw 'must not be reached' } + { Invoke-PluginPostRunBeacon -RunResult (New-TestRunResult) } | Should -Not -Throw + Should -Invoke Invoke-RestMethod -Times 0 -Exactly + Should -Invoke Write-Log -Times 1 -Exactly -ParameterFilter { $Level -eq 'Warn' -and $Message -like '*send failed (run unaffected): FileNotFoundException' } + } + } +}