Bring the share's common scripts under version control

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.
This commit is contained in:
cproudlock
2026-08-11 12:36:38 -04:00
parent b075939c38
commit 54cbe6b5d6
17 changed files with 1795 additions and 0 deletions

View File

@@ -643,6 +643,18 @@ $skipped = 0
$failed = 0
$pcFiltered = 0
# Per-entry outcomes for the caller's report (the API enforce runner captures
# the object emitted at the end). SMB/GE-Enforce.ps1 ignores stdout + reads the
# exit code, so this is additive and does not change the share path.
$script:enforceResults = [System.Collections.Generic.List[object]]::new()
function Add-EnforceResult {
param([string]$Name, [string]$Action, [int]$ExitCode = 0,
[bool]$SelfHealed = $false, [string]$Message = '')
$script:enforceResults.Add([pscustomobject]@{
Name = $Name; Action = $Action; ExitCode = $ExitCode
SelfHealed = $SelfHealed; Message = $Message })
}
foreach ($app in $config.Applications) {
# Cancel any reboot that a prior MSI queued, so the enforcer never
# triggers an unexpected restart on a shopfloor PC.
@@ -658,12 +670,14 @@ foreach ($app in $config.Applications) {
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'PCTypes filter'
continue
}
if (-not (Test-HostnameMatches -App $app)) {
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetHostnames filter'
continue
}
@@ -671,6 +685,7 @@ foreach ($app in $config.Applications) {
$myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message 'TargetMachineNumbers filter'
continue
}
@@ -678,12 +693,14 @@ foreach ($app in $config.Applications) {
$myVer = Get-CurrentCmmVersion
Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping"
$pcFiltered++
Add-EnforceResult -Name $app.Name -Action 'filtered' -Message '_CmmVersion filter'
continue
}
if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping'
$skipped++
Add-EnforceResult -Name $app.Name -Action 'skipped' -Message 'already installed'
continue
}
@@ -723,6 +740,12 @@ foreach ($app in $config.Applications) {
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
$installed++
# SelfHealed = a real drift correction (a detected-missing entry we
# re-installed). Always/no-detection entries install every cycle by
# design and are not self-heals, so the report stays 'ok' for them.
Add-EnforceResult -Name $app.Name -Action 'installed' -ExitCode $rc `
-SelfHealed ([bool]($app.DetectionMethod -and $app.DetectionMethod -ne 'Always')) `
-Message "Exit $rc"
# Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running
@@ -767,11 +790,13 @@ foreach ($app in $config.Applications) {
}
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -ExitCode $rc -Message "Exit $rc - FAILED"
}
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
Add-EnforceResult -Name $app.Name -Action 'failed' -Message $_.Exception.Message
}
}
@@ -781,5 +806,17 @@ Write-InstallLog '============================================'
cmd /c 'shutdown /a 2>nul' *>$null
# Emit the summary object for the API enforce runner to report. Write-Host log
# lines above go to the host stream, so this is the only value on the success
# stream that '& $EnginePath' captures. The exit code is unchanged (SMB path).
Write-Output ([pscustomobject]@{
Installed = $installed
Skipped = $skipped
Failed = $failed
Filtered = $pcFiltered
EnforcerVersion = "$LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR"
Results = $script:enforceResults.ToArray()
})
if ($failed -gt 0) { exit 1 }
exit 0

View File

@@ -0,0 +1,375 @@
# 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