Sixteen files that run on every shopfloor PC existed only on the SFLD share. The cost showed up while debugging the NTLARS backup: the script that posts to ShopDB could not be read, reviewed or diffed, so its behaviour was inferred from log output for most of a day. It turned out to hold a silent fallback that had been governing the whole fleet for months. Imported as-is from tsgwp00525-v2, no edits: lib/ShopdbBackupClient.psm1 the shared backup client scripts/Backup-NtlarsSettings.ps1 converted to use it scripts/Set-ShopdbCollectorKey.ps1 collector credential delivery scripts/Test-RegExport.ps1 exercises the .reg codec with mocks scripts/Set-EventSaver*.ps1 kiosk power / screensaver / disable scripts/Setup-OpenText.* OpenText install + toolbar scripts/Migrate-PCType.ps1, Select-KioskType.ps1, Set-FmsHostsEntry.ps1, scripts/ensure-vnc-firewall.ps1, Install-AcroReader.cmd, Install-Oracle11r2.cmd lib/Install-FromManifest.ps1 is also updated from the share, which was 37 lines AHEAD of this repo and purely additive: the Add-EnforceResult reporting added during the kiosk API cutover, done live and never committed back. Nothing was removed. Checked for embedded secrets before committing; there are none. Set-ShopdbCollectorKey deliberately reads its token from a sibling file on the share rather than holding it, so the script is safe to track. The share remains what actually runs. This makes it reviewable, and makes the next drift visible as a diff rather than a surprise.
376 lines
16 KiB
PowerShell
376 lines
16 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'
|
|
|
|
|
|
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-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 { }
|
|
}
|
|
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,
|
|
[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 }
|
|
|
|
$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) `
|
|
-ContentType 'application/json' `
|
|
-Headers @{ 'X-API-Key' = $Context.CollectorKey } `
|
|
-TimeoutSec $TimeoutSec -EA Stop
|
|
|
|
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
|
|
}
|
|
}
|
|
|
|
|
|
Export-ModuleMember -Function Initialize-ShopdbBackup, Send-ShopdbBackup,
|
|
Write-ShopdbBackupLog, Write-ShopdbQuietState, Clear-ShopdbQuietState,
|
|
Get-ShopdbRegValue, Get-ShopdbCollectorKey, Get-ShopdbMachineNumber,
|
|
Get-ShopdbIntervalHours, Get-ShopdbLogPath
|