Files
pxe-server/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1
cproudlock b228a69364 Identify a marker by its device, without moving what NTLARS reports
A part marker has no machine number of its own - the device is what a backup
belongs to, and C:\Enrollment\asset-id.txt is what names it. Reading that file
inside the shared machine-number lookup looked like the tidy place for it and
was wrong: marker bays run eDNC too, so Backup-NtlarsSettings.ps1 reaches the
same lookup, and it would have started posting a machine's DNC settings under
the marker's asset number where today it exits quietly. The marker script asks
for the fallback explicitly instead, which is what -MachineNumberFallback is
for.

Also from review:

Returning a byte array bare lets the pipeline enumerate it, so an EMPTY file
came back as $null, ComputeHash threw, and with ErrorActionPreference=Continue
the manifest entry silently kept the previous file's hash - a corrupted
manifest stored as a good revision. Empty parameter files are plausible.

Reading registry values directly under StrictMode throws when one is absent, so
the "no Port Id" guard was unreachable and a partially configured marker bay
reported itself as having no marker at all, permanently and quietly. Values are
now read through a presence check with the fleet's own defaults.

The outside-window quiet state embedded the hour, so the say-once module said
it hourly. Command replies now have their checksum verified like data blocks
do, since a corrupted listing entry would otherwise be trusted and a file
requested under a name the controller does not have. A controller that
genuinely holds nothing is no longer reported as a failed capture. A snapshot
that could not be written to the share now counts as a missed backup like the
rest, and the header no longer claims orphaned snapshots get tidied - they are
kept on purpose, because a timed-out post may still have been recorded.
2026-08-25 13:21:45 -04:00

527 lines
22 KiB
PowerShell

# ShopdbBackupClient - the client half of the ShopDB asset-backup contract.
#
# WHY THIS EXISTS
#
# ShopDB's backups plugin is a pluggable registry: a "kind" declares how a
# config is parsed, rendered and resolved to an asset, and in return gets
# revision history, content dedup, retention, diffs and an asset panel. The
# server half is a clean contract. The client half was not: exactly one script
# on this share posted backups (Backup-NtlarsSettings), and everything around
# the post - finding the collector key, reading the interval, throttling,
# logging - lived inside it, bespoke.
#
# The cost showed up the day someone looked. The interval came from a public
# settings key the plugin never declared public, so the read silently fell back
# to a hardcoded 24 hours and the setting did nothing for months. The log wrote
# three lines every five minutes whatever happened, reaching 3,234 lines of
# which 3,217 were the same "Throttled" line. Seven other Backup-*.ps1 scripts
# on this share capture device configs and post NONE of them, so nothing about
# a CMM, a PC-DMIS bay or an MTConnect box has any history in ShopDB.
#
# Wiring those seven the old way would have produced seven more copies of all
# of the above. So the shared parts live here, once, and a per-device script
# becomes: capture the config, call Send-ShopdbBackup.
#
# USAGE
#
# Import-Module "$PSScriptRoot\..\lib\ShopdbBackupClient.psm1" -Force
# $ctx = Initialize-ShopdbBackup -Kind 'gocmm'
# if (-not $ctx.Proceed) { exit 0 } # quiet exit, already logged
# $bytes = [IO.File]::ReadAllBytes($configPath)
# Send-ShopdbBackup -Context $ctx -Bytes $bytes -SourceFileName 'settings.xml'
#
# Initialize-ShopdbBackup does every check that can say "nothing to do today":
# machine number, collector key, base URL, throttle. Each of those is logged
# ONCE and then stays quiet while it holds, so a PC with no NTLARS does not
# write the same line 288 times a day.
#
# PER-KIND STATE, deliberately. Marker, state and log files are all named from
# the kind, so a bay running two backup kinds does not have them fighting over
# one marker file - which is what a single fixed name would have caused the
# first time a second kind shipped.
Set-StrictMode -Version Latest
$script:LOGDIR = 'C:\Logs\Shopfloor'
$script:SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB'
$script:KEYFILE = 'C:\Enrollment\shopdb-key.txt'
$script:PCCONFIG = 'C:\Enrollment\pc-config.txt'
# The DEVICE this PC drives, by asset number - a part marker, a CMM, a Keyence.
# Written at staging and read by the computers collector, which resolves it with
# _asset_by_number and refuses a name it cannot find rather than minting a
# phantom. Marker and measuring-tool bays have no machine number of their own
# ("keyence / genspect / part-marker have no per-bay id" in
# Report-AssetToShopDB.ps1), so for those this file IS the identity.
$script:ASSETIDFILE = 'C:\Enrollment\asset-id.txt'
# 0.12.0 shipped this name before the file was generalised; a bay staged then
# still has it.
$script:LEGACYASSETIDFILE = 'C:\Enrollment\measuringtool-id.txt'
# The action ShopDB reported for the most recent post, per kind: 'created',
# 'noop', or '' when nothing has been posted. Send-ShopdbBackup returns only
# accepted/not-accepted, and changing that would break its existing caller - but
# a share kind has to know the difference, because it decides whether the
# snapshot directory it just wrote is a new revision or a duplicate to remove.
$script:LASTACTION = @{}
function Get-ShopdbLogPath {
param([Parameter(Mandatory)][string]$Kind)
# Date-stamped at source. An append-only name can never age out of the
# GE-Enforce retention sweep, which drops by LastWriteTime - a file written
# every cycle is always "recent" and grows forever.
Join-Path $script:LOGDIR ('{0}-backup-{1}.log' -f $Kind, (Get-Date -Format 'yyyyMMdd'))
}
function Write-ShopdbBackupLog {
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Message)
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
$line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Add-Content -Path (Get-ShopdbLogPath -Kind $Kind) -Value $line -EA SilentlyContinue
Write-Host $line
}
function Write-ShopdbQuietState {
<#
Log a no-change outcome once, then stay silent while it holds.
Returns nothing; the caller exits on a quiet outcome.
GE-Enforce runs every 5 minutes. An outcome that does not change is
worth saying once, not 288 times a day. Any real event clears the state
so the next quiet spell announces itself - otherwise a PC that stopped
working would look exactly like one still posting.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$State,
[Parameter(Mandatory)][string]$Message)
$statefile = Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)
$previous = ''
if (Test-Path $statefile) {
try { $previous = (Get-Content -LiteralPath $statefile -First 1 -EA Stop).Trim() } catch { }
}
if ($previous -ne $State) {
Write-ShopdbBackupLog -Kind $Kind -Message $Message
Set-Content -Path $statefile -Value $State -EA SilentlyContinue
}
}
function Clear-ShopdbQuietState {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.state' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbRegValue {
<#
One value from HKLM:\SOFTWARE\GE\ShopDB.
The SAME contract the display kiosks use: Install-ShopdbKiosk writes
BaseUrl / ApiToken / CollectorKey there and ACLs it to SYSTEM and
Administrators. Machine bays read the same place rather than inventing
a second home for one secret, so one delivery mechanism serves the whole
fleet and the diagnostics collector already knows to redact it.
#>
param([Parameter(Mandatory)][string]$Name)
try {
$props = Get-ItemProperty -Path $script:SHOPDBREG -EA Stop
if ($props.PSObject.Properties.Name -contains $Name) {
$val = [string]$props.$Name
if ($val) { return $val.Trim() }
}
} catch { }
return ''
}
function Get-ShopdbCollectorKey {
# Registry first (how the fleet is provisioned), then the enrollment file
# for a bay staged before that existed.
$key = Get-ShopdbRegValue -Name 'CollectorKey'
if ($key) { return $key }
if (Test-Path $script:KEYFILE) {
foreach ($line in (Get-Content -LiteralPath $script:KEYFILE -EA SilentlyContinue)) {
if ($line -match '^\s*collector\s*=\s*(.+)$') { return $matches[1].Trim() }
}
}
return ''
}
function Get-ShopdbAssetId {
<#
The asset number of the device this PC drives, or ''.
Same file and same precedence the computers collector uses, so a bay
cannot report its marker under one identity and its backup under
another.
#>
foreach ($file in @($script:ASSETIDFILE, $script:LEGACYASSETIDFILE)) {
if (Test-Path $file) {
try {
$value = ([string](Get-Content -LiteralPath $file -First 1 -EA Stop)).Trim()
if ($value) { return $value }
} catch { }
}
}
return ''
}
function Get-ShopdbMachineNumber {
<#
The machine number this PC reports, from pc-config.txt.
A device-specific fallback can be supplied when the application itself
knows the number - NTLARS keeps one in its own registry key. Callers
without one just get '' and exit quietly.
#>
param([scriptblock]$Fallback)
if (Test-Path $script:PCCONFIG) {
foreach ($line in (Get-Content -LiteralPath $script:PCCONFIG -EA SilentlyContinue)) {
if ($line -match '^\s*machine(number|no)?\s*=\s*(.+)$') {
$val = $matches[2].Trim()
# 9999 is the imaging-time placeholder, not a real bay.
if ($val -and $val -ne '9999') { return $val }
}
}
}
if ($Fallback) {
try {
$val = & $Fallback
if ($val) { return ([string]$val).Trim() }
} catch { }
}
# NO asset-id.txt fallback here, deliberately. It belongs to the CALLER.
#
# A part-marker bay has no machine number of its own but does have the DNC
# registry tree, so Backup-NtlarsSettings.ps1 passes its key check and
# reaches this function. A shared fallback would make it post the machine's
# DNC settings under the MARKER's asset number - a kind that resolves by
# machine number, handed a device asset number - where today it exits
# quietly. That is a behaviour change to a script running in production.
#
# A kind that legitimately identifies itself by device passes
# -MachineNumberFallback { Get-ShopdbAssetId } instead. The per-kind
# mechanism already exists; this is what it is for.
return ''
}
function Get-ShopdbIntervalHours {
<#
Minimum hours between attempts, from the backups_intervalhours setting.
Read UNAUTHENTICATED from /api/settings/public, because this runs before
any credential is needed. That endpoint serves an allowlist, and the key
must be declared public by the plugin - it was not, for months, so this
read returned nothing and the fallback below silently governed the whole
fleet. The fallback stays (an unreachable server must not mean a hot
loop) but a miss is now LOGGED rather than swallowed, so the same
failure cannot hide again.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$BaseUrl,
[int]$Default = 24)
try {
$resp = Invoke-RestMethod -Uri "$BaseUrl/api/settings/public" -Method Get `
-TimeoutSec 10 -EA Stop
$val = $null
if ($resp -and $resp.PSObject.Properties.Name -contains 'data') {
if ($resp.data.PSObject.Properties.Name -contains 'backups_intervalhours') {
$val = $resp.data.backups_intervalhours
}
}
if ($val) { return [int]$val }
Write-ShopdbQuietState -Kind $Kind -State 'interval-not-public' -Message (
'backups_intervalhours is not readable from /api/settings/public; ' +
"using the built-in ${Default}h. The plugin must declare the key public.")
} catch {
Write-ShopdbQuietState -Kind $Kind -State 'interval-unreachable' -Message (
"Could not read settings from $BaseUrl ($($_.Exception.Message)); using ${Default}h.")
}
return $Default
}
function Initialize-ShopdbBackup {
<#
Every check that can say "nothing to do", in one call.
Returns a context object. .Proceed is $false when the run should stop -
the reason has already been logged, once. On $true the context carries
Kind, BaseUrl, CollectorKey, MachineNumber and IntervalHours, and the
marker has NOT yet been stamped (Send-ShopdbBackup does that).
#>
param([Parameter(Mandatory)][string]$Kind,
[string]$BaseUrl,
[scriptblock]$MachineNumberFallback,
[switch]$Force)
$ctx = [pscustomobject]@{
Kind = $Kind
Proceed = $false
BaseUrl = ''
CollectorKey = ''
MachineNumber = ''
IntervalHours = 24
}
$machineNumber = Get-ShopdbMachineNumber -Fallback $MachineNumberFallback
if (-not $machineNumber) {
Write-ShopdbQuietState -Kind $Kind -State 'no-machine-number' -Message (
'No machine number in pc-config.txt and no device fallback. ' +
'A backup cannot be filed against an asset - skipping.')
return $ctx
}
$collectorKey = Get-ShopdbCollectorKey
if (-not $collectorKey) {
Write-ShopdbQuietState -Kind $Kind -State 'no-collector-key' -Message (
"No collector key in $script:SHOPDBREG\CollectorKey or $script:KEYFILE. " +
'The collector endpoint has no IP-allowlist path, unlike the GE-Enforce ' +
'manifest fetch, so it always needs a collector-scoped token. Skipping.')
return $ctx
}
if (-not $BaseUrl) { $BaseUrl = Get-ShopdbRegValue -Name 'BaseUrl' }
if (-not $BaseUrl) {
Write-ShopdbQuietState -Kind $Kind -State 'no-base-url' -Message (
"No ShopDB BaseUrl in $script:SHOPDBREG and none passed. Skipping.")
return $ctx
}
$BaseUrl = $BaseUrl.TrimEnd('/')
$intervalHours = Get-ShopdbIntervalHours -Kind $Kind -BaseUrl $BaseUrl
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $Kind)
if (-not $Force -and (Test-Path $markerfile)) {
try {
$last = (Get-Item $markerfile).LastWriteTime
if (((Get-Date) - $last).TotalHours -lt $intervalHours) {
$due = $last.AddHours($intervalHours).ToString('yyyy-MM-dd HH:mm')
Write-ShopdbQuietState -Kind $Kind -State ("throttled-$due") -Message (
"Throttled: posted within the last ${intervalHours}h, next attempt after $due. " +
'Use -Force to override.')
return $ctx
}
} catch { }
}
$ctx.Proceed = $true
$ctx.BaseUrl = $BaseUrl
$ctx.CollectorKey = $collectorKey
$ctx.MachineNumber = $machineNumber
$ctx.IntervalHours = $intervalHours
Clear-ShopdbQuietState -Kind $Kind
Write-ShopdbBackupLog -Kind $Kind -Message (
"=== $Kind backup start === machine $machineNumber, $BaseUrl, interval ${intervalHours}h")
return $ctx
}
function Send-ShopdbBackup {
<#
POST one captured config to the ShopDB collector.
-Bytes for a kind ShopDB parses and stores (storagebackend 'shopdb').
-ContentHash with -SharePath for a kind whose bytes stay on the share
(storagebackend 'share'), where ShopDB keeps metadata and a pointer.
Returns $true when ShopDB accepted it, whether that produced a new
revision or a no-op; an unchanged config is a no-op by design and is
the expected outcome most cycles.
#>
param([Parameter(Mandatory)][pscustomobject]$Context,
[byte[]]$Bytes,
[string]$ContentHash,
[string]$SharePath,
[string]$SourceFileName,
# Optional description of a share kind's snapshot - what is in it, not
# the bytes. ShopDB stores it so an asset panel can list the contents
# and diff them against the previous snapshot; without it the server
# would have to mount the share, which it refuses to do. Advisory:
# dedup still keys on ContentHash.
[hashtable]$Manifest,
[int]$TimeoutSec = 30)
$kind = $Context.Kind
# Marker BEFORE the post, deliberately. If ShopDB is unreachable we do not
# want every cycle for the rest of the day retrying; the next window picks
# it up.
$markerfile = Join-Path $script:LOGDIR ('{0}-backup.marker' -f $kind)
Set-Content -Path $markerfile -Value (Get-Date -Format 'o') -EA SilentlyContinue
# sourcehostname is load-bearing now, not just informational: ShopDB
# resolves a part-marker PC's backup to ITS marker through this field, and
# dedup keys a revision chain on it. An empty value silently files the
# backup against the operation instead and merges two devices' histories,
# so fall back to the DNS name rather than posting a blank.
$sourcehost = $env:COMPUTERNAME
if (-not $sourcehost) {
try { $sourcehost = [System.Net.Dns]::GetHostName() } catch { $sourcehost = '' }
}
$payload = @{
machinenumber = $Context.MachineNumber
backupkind = $kind
sourcehostname = $sourcehost
collectedat = (Get-Date).ToUniversalTime().ToString('o')
}
if ($SourceFileName) { $payload['sourcefilename'] = $SourceFileName }
if ($Bytes) {
$payload['contentbase64'] = [Convert]::ToBase64String($Bytes)
$payload['bytesize'] = $Bytes.Length
}
if ($ContentHash) { $payload['contenthash'] = $ContentHash }
if ($SharePath) { $payload['sharepath'] = $SharePath }
if ($Manifest) { $payload['manifest'] = $Manifest }
$uri = '{0}/api/collector/backups' -f $Context.BaseUrl
$size = if ($Bytes) { '{0} bytes' -f $Bytes.Length } else { $SharePath }
Write-ShopdbBackupLog -Kind $kind -Message (
'Posting {0} for machine {1}' -f $size, $Context.MachineNumber)
try {
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$resp = Invoke-RestMethod -Uri $uri -Method Post `
-Body ($payload | ConvertTo-Json -Compress -Depth 6) `
-ContentType 'application/json' `
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
-TimeoutSec $TimeoutSec -EA Stop
$script:LASTACTION[$kind] = "$($resp.data.action)"
switch ("$($resp.data.action)") {
'created' { Write-ShopdbBackupLog -Kind $kind -Message (
'New revision {0} recorded.' -f $resp.data.backuprevisionid) }
'noop' { Write-ShopdbBackupLog -Kind $kind -Message 'Settings unchanged - no new revision (expected most cycles).' }
default { Write-ShopdbBackupLog -Kind $kind -Message (
"ShopDB returned action '{0}'." -f $resp.data.action) }
}
foreach ($warning in @($resp.data.warnings)) {
if ($warning) { Write-ShopdbBackupLog -Kind $kind -Message " WARNING: $warning" }
}
return $true
} catch {
# A 400 here is usually meaningful rather than transient: an
# unconfigured device, or a machine number ShopDB does not know. Log the
# server's own message so the cause is visible at the bay.
$detail = $_.Exception.Message
try {
$stream = $_.Exception.Response.GetResponseStream()
$reader = New-Object IO.StreamReader($stream)
$body = $reader.ReadToEnd()
if ($body) { $detail = $body }
} catch { }
Write-ShopdbBackupLog -Kind $kind -Message "Post failed: $detail"
return $false
}
}
function Add-ShopdbBackupSkip {
<#
Record that a cycle was skipped, and why.
A skipped cycle posts NOTHING to ShopDB, deliberately: lastseenat means
"this config was confirmed still current", so posting a no-op to keep a
marker green would be a lie that hides a backup which has not run for a
month. The staleness badge is what surfaces that.
What ShopDB cannot know is WHY, so the count and the reason are kept
locally and carried into the next successful snapshot's manifest. A
marker that captures once a week because its port is busy every shift
change then says so on its own asset page.
#>
param([Parameter(Mandatory)][string]$Kind,
[Parameter(Mandatory)][string]$Reason)
$file = Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)
$count = 0
$first = (Get-Date).ToUniversalTime().ToString('o')
if (Test-Path $file) {
try {
$existing = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json
$count = [int]$existing.count
if ($existing.first) { $first = [string]$existing.first }
} catch { }
}
if (-not (Test-Path $script:LOGDIR)) {
New-Item -ItemType Directory -Path $script:LOGDIR -Force -EA SilentlyContinue | Out-Null
}
[pscustomobject]@{
count = $count + 1
first = $first
last = (Get-Date).ToUniversalTime().ToString('o')
reason = $Reason
} | ConvertTo-Json -Compress | Set-Content -Path $file -EA SilentlyContinue
}
function Get-ShopdbBackupSkips {
<#
Skipped cycles since the last successful capture: @{count; first; last;
reason}, or $null when none. Read into the manifest, then cleared by
Clear-ShopdbBackupSkips once a capture actually succeeds.
#>
param([Parameter(Mandatory)][string]$Kind)
$file = Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)
if (-not (Test-Path $file)) { return $null }
try {
$data = Get-Content -LiteralPath $file -Raw | ConvertFrom-Json
return @{
count = [int]$data.count
first = [string]$data.first
last = [string]$data.last
reason = [string]$data.reason
}
} catch { return $null }
}
function Clear-ShopdbBackupSkips {
param([Parameter(Mandatory)][string]$Kind)
Remove-Item -LiteralPath (Join-Path $script:LOGDIR ('{0}-backup.skips' -f $Kind)) `
-Force -EA SilentlyContinue
}
function Get-ShopdbLastBackupAction {
<#
What ShopDB did with the most recent post for this kind: 'created',
'noop', or '' if nothing was posted or the post failed.
#>
param([Parameter(Mandatory)][string]$Kind)
if ($script:LASTACTION.ContainsKey($Kind)) { return $script:LASTACTION[$Kind] }
return ''
}
function Test-ShopdbBackupNoop {
<#
True when the last post for this kind changed nothing. A share kind uses
this to delete the snapshot it just wrote: a no-op proves byte-identical
content already exists in the previous snapshot, so removing the copy
loses nothing and stops one identical directory accumulating per cycle.
#>
param([Parameter(Mandatory)][string]$Kind)
return ((Get-ShopdbLastBackupAction -Kind $Kind) -eq 'noop')
}
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
Get-ShopdbIntervalHours, Get-ShopdbLogPath,
Get-ShopdbLastBackupAction, Test-ShopdbBackupNoop,
Add-ShopdbBackupSkip, Get-ShopdbBackupSkips, Clear-ShopdbBackupSkips,
Get-ShopdbAssetId