# 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