diff --git a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 index fe7e101..686ead3 100644 --- a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 +++ b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 @@ -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 diff --git a/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 b/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 new file mode 100644 index 0000000..8ab9c39 --- /dev/null +++ b/playbook/shopfloor-setup/common/lib/ShopdbBackupClient.psm1 @@ -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 diff --git a/playbook/shopfloor-setup/common/scripts/Backup-NtlarsSettings.ps1 b/playbook/shopfloor-setup/common/scripts/Backup-NtlarsSettings.ps1 new file mode 100644 index 0000000..3192569 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Backup-NtlarsSettings.ps1 @@ -0,0 +1,203 @@ +# Backup-NtlarsSettings.ps1 +# +# Captures this PC's LIVE NTLARS/DNC registry settings and posts them to ShopDB, +# which files them against the MACHINE (not this PC) and keeps a revision +# history. A tech can then re-download the .reg from the machine's page instead +# of hunting for a per-machine file on the share. +# +# Runs from the SFLD share every GE-Enforce cycle as a Type=PS1 manifest entry +# with DetectionMethod=Always. Updating this file on the share changes fleet +# behaviour on the next cycle - there is no local copy to heal. +# +# WHY WOW6432Node IS EXPLICIT: +# NTLARS is a 32-bit app, so its settings physically live under +# HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC. GE-Enforce runs this +# script in 64-bit PowerShell, where HKLM:\SOFTWARE\GE Aircraft Engines\DNC +# is a DIFFERENT (usually absent) key. Reading the unredirected path would +# find nothing and back up an empty config - silently, every cycle. The path +# below is therefore spelled out and never abbreviated. +# +# Note the asymmetry with what NTLARS itself writes: its Save... button +# exports WITHOUT the WOW6432Node segment. ShopDB accepts either dialect and +# stores a dialect-neutral projection, so this script does not need to care. +# +# THROTTLE, LOGGING, CREDENTIALS: +# All handled by common\lib\ShopdbBackupClient.psm1, which every backup kind +# shares. GE-Enforce fires this every cycle, so the module holds the marker +# file, exits early until backups_intervalhours has elapsed, and logs a +# no-change outcome ONCE rather than every five minutes. The interval comes +# from ShopDB, so cadence is changed centrally rather than by editing this +# file on the share. +# +# Always exits 0 so the GE-Enforce "last run result" stays clean. Failures are +# logged, never thrown. + +param( + [string]$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb', + [int]$TimeoutSec = 30, + # Force a post regardless of the throttle. For a tech capturing a + # known-good config on demand. + [switch]$Force +) + +$ErrorActionPreference = 'Continue' + +# The only path this script still owns. Log, marker, state, collector key, +# enrollment and base URL all moved to ShopdbBackupClient, which names its files +# per KIND so two backup kinds on one bay cannot collide. +$DNCKEY = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC' + +function Convert-RegTypeName { + param($Kind) + switch ("$Kind") { + 'String' { 'REG_SZ' } + 'ExpandString' { 'REG_EXPAND_SZ' } + 'DWord' { 'REG_DWORD' } + 'QWord' { 'REG_QWORD' } + 'MultiString' { 'REG_MULTI_SZ' } + 'Binary' { 'REG_BINARY' } + default { 'REG_SZ' } + } +} + +function Get-DncKeys { + <# + The DNC key and every subkey, root first. Split out from + Export-DncToReg so the formatting logic can be exercised against mock + keys on a machine with no registry (see Test-RegExport.ps1). + #> + $keys = @(Get-Item -Path $DNCKEY -EA Stop) + $keys += @(Get-ChildItem -Path $DNCKEY -Recurse -EA SilentlyContinue) + return $keys +} + +function Export-DncToReg { + <# + Emits .reg text in the WOW6432Node dialect for the supplied keys. + + Built by hand rather than shelling out to `reg export` because reg.exe + writes UTF-16 to a temp file we would then have to read back, and + because this keeps the value types explicit instead of reparsing them. + + Takes the key list as a parameter so it can be tested with mocks; the + escaping and dword formatting here are the part that would corrupt a + backup silently and only surface at restore time. + #> + param([Parameter(Mandatory)]$Keys) + + $lines = @('Windows Registry Editor Version 5.00', '') + $lines += "; NTLARS DNC Registry Backup" + $lines += "; Computer: $env:COMPUTERNAME" + $lines += "; Date: $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" + $lines += '' + + foreach ($key in $Keys) { + # PSPath -> the literal HKEY_LOCAL_MACHINE\... form a .reg file needs. + $path = $key.Name -replace '^HKEY_LOCAL_MACHINE', 'HKEY_LOCAL_MACHINE' + $lines += "[$path]" + foreach ($name in $key.GetValueNames()) { + $kind = Convert-RegTypeName $key.GetValueKind($name) + $data = $key.GetValue($name) + $lhs = if ($name -eq '') { '@' } else { + # .NET replacement strings do NOT process backslash escapes, so + # the replacement is the literal output: '\\' emits two + # backslashes, which is what .reg escaping wants. Writing + # '\\\\' here emits FOUR and silently corrupts every path-valued + # setting - verified on Windows before this was fixed. + '"{0}"' -f ($name -replace '\\', '\\' -replace '"', '\"') + } + switch ($kind) { + 'REG_DWORD' { + $lines += ('{0}=dword:{1:x8}' -f $lhs, [uint32]$data) + } + 'REG_BINARY' { + $hex = ($data | ForEach-Object { '{0:x2}' -f $_ }) -join ',' + $lines += ('{0}=hex:{1}' -f $lhs, $hex) + } + 'REG_QWORD' { + $bytes = [BitConverter]::GetBytes([uint64]$data) + $hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ',' + $lines += ('{0}=hex(b):{1}' -f $lhs, $hex) + } + 'REG_MULTI_SZ' { + $joined = (($data -join "`0") + "`0`0") + $bytes = [Text.Encoding]::Unicode.GetBytes($joined) + $hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ',' + $lines += ('{0}=hex(7):{1}' -f $lhs, $hex) + } + 'REG_EXPAND_SZ' { + $bytes = [Text.Encoding]::Unicode.GetBytes(("$data" + "`0")) + $hex = ($bytes | ForEach-Object { '{0:x2}' -f $_ }) -join ',' + $lines += ('{0}=hex(2):{1}' -f $lhs, $hex) + } + default { + $escaped = ("$data" -replace '\\', '\\' -replace '"', '\"') + $lines += ('{0}="{1}"' -f $lhs, $escaped) + } + } + } + $lines += '' + } + return ($lines -join "`r`n") + "`r`n" +} + +# ============================================================================= +# Main +# +# Guarded so the file can be DOT-SOURCED to get the functions without running a +# backup. Test-RegExport.ps1 relies on this to exercise Export-DncToReg against +# mock keys on a machine with no registry. +# ============================================================================= + +if ($MyInvocation.InvocationName -eq '.') { return } + +# The plumbing - key lookup, base URL, interval, throttle, logging, the POST and +# its response handling - lives in ShopdbBackupClient. It used to live here, and +# being the only implementation meant every defect in it was invisible: the +# interval read fell back to a hardcoded 24h for months because nobody had a +# second copy to compare against, and the log wrote three lines every five +# minutes whatever happened. Seven other Backup-*.ps1 scripts on this share +# capture device configs and post none of them; they can now do so without +# inheriting a copy of all that. +# +# What stays here is the part that is actually about NTLARS: where its registry +# lives, and how to turn it into .reg text. +Import-Module (Join-Path $PSScriptRoot '..\lib\ShopdbBackupClient.psm1') -Force + +if (-not (Test-Path $DNCKEY)) { + # Not an error: plenty of PC types have no NTLARS at all. On those PCs this + # is the permanent state, so it is said once rather than 288 times a day. + Write-ShopdbQuietState -Kind 'ntlars' -State 'no-dnc-key' -Message ( + "No DNC key at $DNCKEY - NTLARS is not installed on this PC. Nothing to do.") + exit 0 +} + +# NTLARS keeps its own MachineNo, used only when pc-config.txt has none. ShopDB +# compares the two and warns on a mismatch rather than silently trusting one. +$ctx = Initialize-ShopdbBackup -Kind 'ntlars' -Force:$Force ` + -BaseUrl $(if ($PSBoundParameters.ContainsKey('BaseUrl')) { $BaseUrl } else { '' }) ` + -MachineNumberFallback { + try { + $general = Get-ItemProperty -Path (Join-Path $DNCKEY 'General') -EA Stop + if ($general.MachineNo) { return ([string]$general.MachineNo).Trim() } + } catch { } + return '' + } +if (-not $ctx.Proceed) { exit 0 } + +try { + $regText = Export-DncToReg -Keys (Get-DncKeys) +} catch { + Write-ShopdbBackupLog -Kind 'ntlars' -Message "Failed to read the DNC key: $_" + exit 0 +} + +# UTF-16LE + BOM, matching what regedit and NTLARS emit. ShopDB sniffs the BOM, +# so this is belt-and-braces rather than strictly required. +$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($regText) + +[void](Send-ShopdbBackup -Context $ctx -Bytes $bytes ` + -SourceFileName ("{0}.reg" -f $ctx.MachineNumber) -TimeoutSec $TimeoutSec) + +Write-ShopdbBackupLog -Kind 'ntlars' -Message '=== Backup-NtlarsSettings end ===' +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd b/playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd new file mode 100755 index 0000000..df07fbc --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Install-AcroReader.cmd @@ -0,0 +1,33 @@ +@echo off +REM Install-AcroReader.cmd - Install Adobe Acrobat Reader DC + DC update patch +REM +REM Two-step install: base MSI with enterprise transform, then DC update patch. +REM Script lives in /scripts/; MSI + MST + MSP + CAB live in sibling +REM /apps/. pushd into apps\ so the MSI's Media-table CAB reference +REM (Data1.cab) resolves against its sibling, and msiexec doesn't choke on +REM a mapped-drive path with ..\ normalization (was returning 1619). + +setlocal +pushd "%~dp0..\apps" + +echo Installing Adobe Acrobat Reader DC... +msiexec /i "AcroRead.msi" TRANSFORMS="AcroRead.mst" /quiet /norestart +set RC=%errorlevel% +if %RC% neq 0 if %RC% neq 3010 ( + echo Acrobat Reader MSI failed with exit code %RC% + popd + exit /b %RC% +) + +echo Applying Adobe Reader DC update patch... +msiexec /p "AcroRdrDCUpd2500120531.msp" /quiet /norestart +set RC=%errorlevel% +if %RC% neq 0 if %RC% neq 3010 ( + echo Acrobat Reader patch failed with exit code %RC% + popd + exit /b %RC% +) + +popd +echo Adobe Acrobat Reader DC installed successfully. +exit /b 0 diff --git a/playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd b/playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd new file mode 100755 index 0000000..b888e06 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Install-Oracle11r2.cmd @@ -0,0 +1,75 @@ +@echo off +REM Install-Oracle11r2.cmd +REM Expands the GE Oracle Client 11.2 Administrator zip to a temp dir and +REM runs Oracle Universal Installer silently with the GE-customized +REM response file. +REM +REM Expected layout on the SFLD share (relative to this .cmd): +REM ..\apps\Oracle_OracleDatabase_11r2_V03.zip (686 MB) +REM +REM Called by Install-FromManifest.ps1 (Type=CMD). Exit codes surface back +REM to the enforcer. +REM +REM Oracle 11.2 OUI exit codes worth knowing: +REM 0 = success +REM 3 = success but with warnings +REM 1 = general failure +REM 6 = silent install requested but missing / bad response file + +setlocal enabledelayedexpansion +set "LOG=C:\Logs\OracleClient\install.log" +if not exist "C:\Logs\OracleClient" mkdir "C:\Logs\OracleClient" + +REM Emit a datestamp +for /f "tokens=2 delims==" %%I in ('wmic os get localdatetime /value 2^>nul ^| find "="') do set LDT=%%I +set "STAMP=!LDT:~0,14!" + +echo [%STAMP%] Install-Oracle11r2.cmd starting >> "%LOG%" + +set "SRC_ZIP=%~dp0..\apps\Oracle_OracleDatabase_11r2_V03.zip" +set "STAGING=%TEMP%\oracle-11r2-install" +set "CLIENT_DIR=%STAGING%\Oracle_OracleDatabase_11r2_V03\client" +set "RSP=%CLIENT_DIR%\response\ge_client_install.rsp" + +if not exist "%SRC_ZIP%" ( + echo [%STAMP%] ERROR: zip not found at %SRC_ZIP% >> "%LOG%" + echo ERROR: zip not found at %SRC_ZIP% + exit /b 2 +) + +echo [%STAMP%] Expanding %SRC_ZIP% to %STAGING% >> "%LOG%" +if exist "%STAGING%" rmdir /s /q "%STAGING%" >nul 2>&1 +mkdir "%STAGING%" +powershell.exe -NoProfile -ExecutionPolicy Bypass -Command ^ + "try { Expand-Archive -Path '%SRC_ZIP%' -DestinationPath '%STAGING%' -Force -ErrorAction Stop; exit 0 } catch { Write-Error $_; exit 1 }" ^ + >> "%LOG%" 2>&1 + +if not exist "%CLIENT_DIR%\setup.exe" ( + echo [%STAMP%] ERROR: expanded setup.exe not found at %CLIENT_DIR%\setup.exe >> "%LOG%" + exit /b 3 +) + +if not exist "%RSP%" ( + echo [%STAMP%] ERROR: response file missing at %RSP% >> "%LOG%" + exit /b 4 +) + +echo [%STAMP%] Running OUI silent install (this takes 2-8 minutes) >> "%LOG%" +"%CLIENT_DIR%\setup.exe" -silent -waitforcompletion -nowait ^ + -ignoreSysPrereqs ^ + -responseFile "%RSP%" >> "%LOG%" 2>&1 +set RC=%ERRORLEVEL% + +echo [%STAMP%] OUI exit code: %RC% >> "%LOG%" + +REM Cleanup staging dir to reclaim ~1.5 GB - OUI copies everything to ORACLE_HOME +echo [%STAMP%] Cleaning up staging dir >> "%LOG%" +rmdir /s /q "%STAGING%" >nul 2>&1 + +REM OUI returns 0 for success, 3 for success-with-warnings. Treat both as OK. +if %RC%==3 ( + echo [%STAMP%] OUI reported warnings but install succeeded - returning 0 >> "%LOG%" + exit /b 0 +) + +exit /b %RC% diff --git a/playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1 b/playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1 new file mode 100755 index 0000000..ae494e9 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Migrate-PCType.ps1 @@ -0,0 +1,62 @@ +# Migrate-PCType.ps1 - One-shot in-place rename of legacy pc-type.txt +# values to the gea-shopfloor-* taxonomy. +# +# Idempotent + safe: no-op if pc-type.txt already starts with +# 'gea-shopfloor-'. Decides collections vs nocollections from UDC's +# Uninstall reg presence (collections has UDC, nocollections doesn't). +# Standard-Timeclock + Lab map to gea-shopfloor-common. +# +# Runs every cycle (DetectionMethod=Always in manifest). Cheap because +# the no-op fast path is just one Get-Content + StartsWith check. + +$ErrorActionPreference = 'Continue' + +$typeFile = 'C:\Enrollment\pc-type.txt' +$subTypeFile = 'C:\Enrollment\pc-subtype.txt' + +if (-not (Test-Path $typeFile)) { exit 0 } + +$current = (Get-Content -LiteralPath $typeFile -First 1 -ErrorAction SilentlyContinue).Trim() +if (-not $current) { exit 0 } + +# Already on new taxonomy +if ($current.StartsWith('gea-shopfloor-')) { exit 0 } + +$subType = '' +if (Test-Path $subTypeFile) { + $subType = (Get-Content -LiteralPath $subTypeFile -First 1 -ErrorAction SilentlyContinue).Trim() +} + +# Map legacy -> new +$newType = $null +switch -Regex ($current) { + '^Standard$' { + if ($subType -ieq 'Machine') { + $udcReg = Test-Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\UDC' + $newType = if ($udcReg) { 'gea-shopfloor-collections' } else { 'gea-shopfloor-nocollections' } + } elseif ($subType -ieq 'Timeclock') { + $newType = 'gea-shopfloor-common' + } else { + # Standard with no subtype - default to collections (most common) + $newType = 'gea-shopfloor-collections' + } + } + '^CMM$' { $newType = 'gea-shopfloor-cmm' } + '^Keyence$' { $newType = 'gea-shopfloor-keyence' } + '^Lab$' { $newType = 'gea-shopfloor-common' } + '^WaxAndTrace$' { $newType = 'gea-shopfloor-waxtrace' } + '^Genspect$' { $newType = 'gea-shopfloor-genspect' } + '^Display$' { $newType = 'gea-shopfloor-display' } + '^Heattreat$' { $newType = 'gea-shopfloor-heattreat' } + default { Write-Host "Migrate-PCType: unmapped legacy value '$current' - leaving alone"; exit 0 } +} + +Write-Host "Migrate-PCType: '$current' (subType='$subType') -> '$newType'" +Set-Content -LiteralPath $typeFile -Value $newType -Encoding ascii -ErrorAction Stop + +# Drop pc-subtype.txt - new taxonomy is single-string +if (Test-Path $subTypeFile) { + try { Remove-Item -LiteralPath $subTypeFile -Force -ErrorAction Stop; Write-Host " removed pc-subtype.txt" } catch {} +} + +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1 b/playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1 new file mode 100644 index 0000000..1d82651 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Select-KioskType.ps1 @@ -0,0 +1,68 @@ +# Select-KioskType.ps1 +# +# Imaging-time picker that records which kind of kiosk this PC is, so GE-Enforce +# (share or Flask API) enforces the right scope. Writes the scope name to +# C:\Enrollment\pc-type.txt - the same file every other pc-type reads, and the +# value the Flask client passes as -Scope. +# +# Three kiosk subtypes (split out of the old generic gea-shopfloor-display): +# 1 lobbydisplay -> gea-shopfloor-lobbydisplay (lobby TV, dt\tv\slides) +# 2 dashboard -> gea-shopfloor-dashboard (shopfloor dashboard) +# 3 printerkiosk -> gea-shopfloor-printerkiosk (3D-printer kiosk) +# +# Usage: +# interactive (imaging operator picks): .\Select-KioskType.ps1 +# unattended (automation / task seq): .\Select-KioskType.ps1 -Type dashboard +# already-deployed kiosk (one-time set): .\Select-KioskType.ps1 -Type lobbydisplay +# +# Idempotent: rewrites pc-type.txt to the chosen scope. Always exits 0. + +param( + [ValidateSet('lobbydisplay', 'dashboard', 'printerkiosk')] + [string]$Type, + + [string]$EnrollmentFile = 'C:\Enrollment\pc-type.txt' +) + +$ErrorActionPreference = 'Continue' + +$map = [ordered]@{ + '1' = @{ Key = 'lobbydisplay'; Scope = 'gea-shopfloor-lobbydisplay'; Desc = 'Lobby display (lobby TV / slides)' } + '2' = @{ Key = 'dashboard'; Scope = 'gea-shopfloor-dashboard'; Desc = 'Shopfloor dashboard' } + '3' = @{ Key = 'printerkiosk'; Scope = 'gea-shopfloor-printerkiosk'; Desc = '3D-printer kiosk' } +} + +function Resolve-ScopeFromType([string]$t) { + foreach ($k in $map.Keys) { if ($map[$k].Key -eq $t) { return $map[$k].Scope } } + return $null +} + +$scope = $null +if ($Type) { + $scope = Resolve-ScopeFromType $Type +} else { + Write-Host '' + Write-Host 'Select this PC kiosk type:' -ForegroundColor Cyan + foreach ($k in $map.Keys) { Write-Host (" {0}) {1}" -f $k, $map[$k].Desc) } + Write-Host '' + do { + $choice = Read-Host 'Enter 1, 2, or 3' + } while (-not $map.Contains($choice)) + $scope = $map[$choice].Scope +} + +if (-not $scope) { + Write-Host "ERROR could not resolve a kiosk scope (Type='$Type')." -ForegroundColor Red + exit 0 +} + +try { + $dir = Split-Path -Parent $EnrollmentFile + if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null } + $scope | Set-Content -NoNewline -LiteralPath $EnrollmentFile -ErrorAction Stop + Write-Host "wrote $EnrollmentFile = $scope" -ForegroundColor Green +} catch { + Write-Host "ERROR writing ${EnrollmentFile}: $($_.Exception.Message)" -ForegroundColor Red +} + +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1 b/playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1 new file mode 100755 index 0000000..3e3b35e --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-EventSaverDisable.ps1 @@ -0,0 +1,54 @@ +# Set-EventSaverDisable.ps1 +# +# Turn the EventSaver shopfloor screensaver OFF fleet-wide and undo its +# side effects. Runs under GE-Enforce (SYSTEM) every cycle. Idempotent + +# silent (SYSTEM context - no window). +# +# Self-excludes the canary test host: pass -ExceptHost and this script +# does nothing on that PC, so the (canary-gated) enable entries keep it on +# there while every other shopfloor PC is cleaned. +# +# Undoes: screensaver registry (per-user), the EventSaver-Enable fallback +# task, the never-off power policy, and the staged .scr/.ini. + +[CmdletBinding()] +param( + [string]$ExceptHost = '', + [string]$ScrPath = 'C:\Windows\System32\EventSaver.scr', + [string]$TaskName = 'EventSaver-Enable', + [int] $MonitorMinutes = 15, # restore a sane monitor-off (was Never) + [int] $StandbyMinutes = 20 # restore a sane sleep (was Never) +) + +$ErrorActionPreference = 'Continue' + +# leave the canary host alone - the enable entries own it there +if ($ExceptHost -and ($env:COMPUTERNAME -ieq $ExceptHost)) { exit 0 } + +function Disable-Saver($deskKey) { + if (Test-Path $deskKey) { + Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '0' -Type String -Force -ErrorAction SilentlyContinue + Remove-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -ErrorAction SilentlyContinue + } +} + +# 1. screensaver off in the default profile + every loaded user hive +Disable-Saver 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop' +Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction SilentlyContinue | + Where-Object { $_.PSChildName -match '^S-1-5-21' -and $_.PSChildName -notmatch '_Classes$' } | + ForEach-Object { Disable-Saver "Registry::HKEY_USERS\$($_.PSChildName)\Control Panel\Desktop" } + +# 2. remove the self-clearing fallback task if present +if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue +} + +# 3. restore power (undo the EventSaver never-off) +& powercfg /change monitor-timeout-ac $MonitorMinutes 2>&1 | Out-Null +& powercfg /change standby-timeout-ac $StandbyMinutes 2>&1 | Out-Null + +# 4. remove staged binary + config (harmless if already gone) +Remove-Item -LiteralPath $ScrPath -Force -ErrorAction SilentlyContinue +Remove-Item -LiteralPath ($ScrPath -replace '\.scr$', '.ini') -Force -ErrorAction SilentlyContinue + +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1 b/playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1 new file mode 100755 index 0000000..646893e --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-EventSaverPower.ps1 @@ -0,0 +1,29 @@ +# Set-EventSaverPower.ps1 +# +# Keep shopfloor monitors + PCs awake so the EventSaver ad screensaver is +# actually visible. Runs under GE-Enforce (SYSTEM) every cycle (Always). +# Sets monitor-off + sleep to Never on the ACTIVE power scheme, so a screen +# never blanks out from under the screensaver. Re-applies each cycle, so a +# power-plan change is corrected on the next enforce pass. +# +# Screensaver must trigger before any monitor-off would - with monitor-off +# set to Never here, the screensaver (2-10 min idle) always wins. + +$ErrorActionPreference = 'Continue' + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$log = Join-Path $logDir 'eventsaver.log' +function Write-Log($m) { + Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m) +} + +# 0 = Never. Cover monitor + standby, AC + DC (shopfloor PCs are AC, DC is +# harmless belt-and-suspenders). +& powercfg /change monitor-timeout-ac 0 2>&1 | Out-Null +& powercfg /change monitor-timeout-dc 0 2>&1 | Out-Null +& powercfg /change standby-timeout-ac 0 2>&1 | Out-Null +& powercfg /change standby-timeout-dc 0 2>&1 | Out-Null + +Write-Log "power: monitor-off + sleep set to Never (AC+DC) for EventSaver visibility" +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1 b/playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1 new file mode 100755 index 0000000..277244a --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-EventSaverScreensaver.ps1 @@ -0,0 +1,123 @@ +# Set-EventSaverScreensaver.ps1 +# +# Enable the EventSaver shopfloor screensaver for EVERY user on the box. Runs +# under GE-Enforce (SYSTEM) every cycle. +# +# WHY THIS WAS REWRITTEN (2026-08-06) +# The previous version wrote the timeout to exactly two places: HKU\.DEFAULT, +# which only seeds profiles created AFTERWARDS, and the hive of one profile +# whose folder had to be named literally 'Shopfloor'. On any PC where the +# operator signs in as anything else, no HKCU was ever touched - the screensaver +# then ran on whatever the profile already carried (a domain default, commonly +# 120 seconds), while GE-Enforce reported success every cycle because from its +# point of view it had done its job. +# +# That is what the "screensaver after 2 minutes instead of 9" reports were: the +# manifest said 480 and the machines had never been told. +# +# Now: seed .DEFAULT for future profiles, then apply to EVERY loaded user hive. +# Same approach Set-DisplayAlwaysOn.ps1 already uses for the kiosks. A user who +# signs in between cycles is picked up on the next one. +# +# IDEMPOTENT + SILENT: hives already correct are skipped, so the common path +# writes nothing. + +[CmdletBinding()] +param( + [string]$ScrPath = 'C:\Windows\System32\EventSaver.scr', + [int] $TimeoutSeconds = 540, + # Retired. Kept so an older manifest passing -TargetUser does not fail to + # bind; it is deliberately ignored - targeting one named account is the bug + # this rewrite removes. + [string]$TargetUser = '', + [string]$TaskName = 'EventSaver-Enable' +) + +$ErrorActionPreference = 'Continue' + +$logDir = 'C:\Logs\Shopfloor' +if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } +$log = Join-Path $logDir 'eventsaver.log' +function Write-Log($m) { + Add-Content -LiteralPath $log -Value ("{0} {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $m) +} + +function Get-Val($deskKey, $name) { + return (Get-ItemProperty -Path $deskKey -Name $name -ErrorAction SilentlyContinue).$name +} + +# Already what we want? Idempotency gate - keeps the common path silent. +function Saver-IsSet($deskKey) { + if (-not (Test-Path $deskKey)) { return $false } + if ((Get-Val $deskKey 'ScreenSaveActive') -ne '1') { return $false } + if ((Get-Val $deskKey 'SCRNSAVE.EXE') -ne $ScrPath) { return $false } + if ("$((Get-Val $deskKey 'ScreenSaveTimeOut'))" -ne "$TimeoutSeconds") { return $false } + return $true +} + +function Set-SaverValues($deskKey) { + if (-not (Test-Path $deskKey)) { New-Item -Path $deskKey -Force | Out-Null } + Set-ItemProperty -Path $deskKey -Name 'ScreenSaveActive' -Value '1' -Type String -Force + Set-ItemProperty -Path $deskKey -Name 'SCRNSAVE.EXE' -Value $ScrPath -Type String -Force + Set-ItemProperty -Path $deskKey -Name 'ScreenSaveTimeOut' -Value "$TimeoutSeconds" -Type String -Force + Set-ItemProperty -Path $deskKey -Name 'ScreenSaverIsSecure' -Value '0' -Type String -Force +} + +# --- 1. seed .DEFAULT so profiles created later start correct ----------------- +$defKey = 'Registry::HKEY_USERS\.DEFAULT\Control Panel\Desktop' +if (-not (Saver-IsSet $defKey)) { + try { Set-SaverValues $defKey; Write-Log "seeded .DEFAULT ($TimeoutSeconds s)" } + catch { Write-Log "ERROR seeding .DEFAULT: $_" } +} + +# --- 2. apply to every loaded human hive -------------------------------------- +# Skipped: the three service accounts (SYSTEM, LOCAL SERVICE, NETWORK SERVICE) +# and the _Classes companions, which are not user desktops and would just add +# noise. Everything else that is loaded belongs to somebody signed in now. +$serviceSids = @('S-1-5-18', 'S-1-5-19', 'S-1-5-20') +$applied = 0 +$already = 0 + +try { + $hives = Get-ChildItem 'Registry::HKEY_USERS' -ErrorAction Stop | ForEach-Object { $_.PSChildName } +} catch { + Write-Log "ERROR enumerating HKEY_USERS: $_" + $hives = @() +} + +foreach ($sid in $hives) { + if ($sid -eq '.DEFAULT') { continue } # handled above + if ($sid -like '*_Classes') { continue } + if ($serviceSids -contains $sid) { continue } + if ($sid -notlike 'S-1-5-21-*') { continue } # real domain/local users only + + $hiveKey = "Registry::HKEY_USERS\$sid\Control Panel\Desktop" + if (Saver-IsSet $hiveKey) { $already++; continue } + try { + Set-SaverValues $hiveKey + $applied++ + Write-Log "applied to hive $sid ($TimeoutSeconds s)" + } catch { + Write-Log "ERROR writing hive ${sid}: $_" + } +} + +if ($applied -eq 0 -and $already -eq 0) { + # Nobody signed in - normal during imaging or on an idle bay. .DEFAULT above + # covers the next profile, and the next cycle after a logon covers the rest. + Write-Log 'no user hives loaded; .DEFAULT seeded, will apply on a later cycle' +} + +# --- 3. remove the old per-user fallback task --------------------------------- +# The previous version registered an AtLogon task for the hardcoded 'Shopfloor' +# account. On machines where nobody signs in as that, it sat queued forever and +# never fired. Applying to loaded hives every cycle replaces it, so clear any +# that are still registered. +try { + if (Get-ScheduledTask -TaskName $TaskName -ErrorAction SilentlyContinue) { + Unregister-ScheduledTask -TaskName $TaskName -Confirm:$false -ErrorAction SilentlyContinue + Write-Log "removed stale fallback task '$TaskName'" + } +} catch { } + +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1 b/playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1 new file mode 100755 index 0000000..bae588d --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-FmsHostsEntry.ps1 @@ -0,0 +1,46 @@ +# Set-FmsHostsEntry.ps1 - idempotently pin FMS host in C:\Windows\System32\drivers\etc\hosts. +# +# Why pinned: +# eDNC's FMS prescan (CPreScan::Initialise_Sockets in DncMain.exe and +# CDoPersonnel::InitializeSocket in DNCdll.dll) resolves the FMS host +# via MFC CSocket, which calls inet_addr first then gethostbyname. The +# legacy WinSock1 resolver path fails on the GE corporate network for +# wjfms3.ae.ge.com (modern getaddrinfo path used by PowerShell works +# fine, but eDNC does not use it). Hosts file entry is consulted by +# gethostbyname before any DNS query, so the pin short-circuits the +# broken legacy path. +# +# Idempotent: adds line if missing, leaves it alone if already present. +# Safe to run every cycle (DetectionMethod=Always in manifest). + +$ErrorActionPreference = 'Stop' + +$hostsPath = Join-Path $env:windir 'System32\drivers\etc\hosts' +$ip = '10.233.112.158' +$fqdn = 'WJFMS3.AE.GE.COM' +$line = "$ip`t$fqdn" + +if (-not (Test-Path $hostsPath)) { + Write-Host "hosts file not found at $hostsPath - aborting" + exit 1 +} + +$content = Get-Content -LiteralPath $hostsPath -ErrorAction Stop + +# Match any non-comment line that maps either the IP or the FQDN. +# Drops stale or wrong mappings of the same FQDN/IP, then appends the canonical pin. +$pattern = '(?i)^\s*[^#\s]+\s+\S*' + [regex]::Escape($fqdn) + '\b|^\s*' + [regex]::Escape($ip) + '\s' +$existing = $content | Where-Object { $_ -match $pattern } +$canonical = ($existing | Where-Object { $_ -match "^\s*$([regex]::Escape($ip))\s+$([regex]::Escape($fqdn))\s*$" }) + +if ($canonical -and $existing.Count -eq @($canonical).Count) { + # Already pinned correctly. No change. + exit 0 +} + +# Either no entry, or an entry exists with wrong IP/FQDN/casing/whitespace. Rewrite. +$kept = $content | Where-Object { $_ -notmatch $pattern } +$new = @($kept) + $line +Set-Content -LiteralPath $hostsPath -Value $new -Encoding ascii -ErrorAction Stop +Write-Host "Wrote FMS hosts pin: $line" +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Set-OpenTextToolbar.ps1 b/playbook/shopfloor-setup/common/scripts/Set-OpenTextToolbar.ps1 new file mode 100644 index 0000000..4a76093 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-OpenTextToolbar.ps1 @@ -0,0 +1,58 @@ +# Set-OpenTextToolbar.ps1 - replace the HostExplorer default VT toolbar. +# +# Copies the toolbar shipped in this share over the local one at: +# C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar\ +# +# Runs from the common manifest with DetectionMethod Always, so it also +# self-heals if the file is changed locally. +# +# Deliberately does NOT bump the OpenText version. Setup-OpenText.ps1 skips +# unless HKLM:\SOFTWARE\GE\OpenText\Installed differs from version.txt, so a +# bump re-runs the whole thing - MSI plus the SP1 patch - on every OpenText +# machine over SMB, to ship a 565-byte file. This entry copies just the file. + +$ErrorActionPreference = 'Continue' + +$fileName = 'Default VT Toolbar.tbv' +$src = Join-Path $PSScriptRoot "..\apps\opentext\HostExplorer\Toolbar\$fileName" +$dstDir = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Toolbar' +$dst = Join-Path $dstDir $fileName + +Write-Host '=== OpenText VT toolbar ===' + +if (-not (Test-Path -LiteralPath $src)) { + Write-Host " source missing on share: $src - nothing to do." + return +} + +# Gate on OpenText actually being installed. The Shared root only exists once +# HostExplorer content has been deployed, so its absence means this machine has +# no OpenText and we should not create a stray Hummingbird tree. +$sharedRoot = 'C:\ProgramData\Hummingbird\Connectivity\15.00\Shared' +if (-not (Test-Path -LiteralPath $sharedRoot)) { + Write-Host ' OpenText not installed on this PC - skipping.' + return +} + +# Skip when identical, so an Always entry is not rewriting the file every cycle. +if (Test-Path -LiteralPath $dst) { + $sh = (Get-FileHash -LiteralPath $src -Algorithm SHA256).Hash + $dh = (Get-FileHash -LiteralPath $dst -Algorithm SHA256).Hash + if ($sh -eq $dh) { + Write-Host ' already current - no change.' + return + } + Write-Host ' local copy differs - replacing.' +} else { + Write-Host ' not present locally - installing.' +} + +try { + if (-not (Test-Path -LiteralPath $dstDir)) { + New-Item -ItemType Directory -Path $dstDir -Force -ErrorAction Stop | Out-Null + } + Copy-Item -LiteralPath $src -Destination $dst -Force -ErrorAction Stop + Write-Host " replaced $dst" +} catch { + Write-Warning " failed to replace ${dst}: $_" +} diff --git a/playbook/shopfloor-setup/common/scripts/Set-ShopdbCollectorKey.ps1 b/playbook/shopfloor-setup/common/scripts/Set-ShopdbCollectorKey.ps1 new file mode 100644 index 0000000..54a4bd9 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Set-ShopdbCollectorKey.ps1 @@ -0,0 +1,137 @@ +# Set-ShopdbCollectorKey.ps1 +# +# Delivers the ShopDB collector credential to every shopfloor PC by writing +# HKLM:\SOFTWARE\GE\ShopDB (BaseUrl + CollectorKey), the same contract the +# display kiosks already use. Anything that posts to /api/collector/* reads it +# from there - today Backup-NtlarsSettings.ps1, tomorrow whatever else reports. +# +# WHY A KEY IS NEEDED AT ALL: +# The GE-Enforce manifest/payload FETCH honours an IP allowlist, so a bay on a +# trusted subnet pulls config with no token. Collector INGEST does not: it +# accepts only a collector-scoped token. That asymmetry is deliberate - fetch +# reads config we already publish, ingest WRITES asset data, and an allowlist +# alone would let anything on the subnet post revisions. +# +# WHERE THE SECRET LIVES: +# NOT in manifest.json, and not in this script. It is read from a sibling file +# on the share (see $KEYCONFIG below), so: +# - the manifest stays free of secrets and safe to read/diff +# - rotating the token is replacing ONE file, not editing a manifest +# The file inherits the share's ACL, which grants file-level reads only to the +# SFLD user. Treat it as a secret: scope the token to collector.ingest ONLY, +# so a leak cannot read or mutate anything else. +# +# IDEMPOTENT: compares current registry values against desired and writes only +# on a difference, so DetectionMethod=Always costs one registry read per cycle +# after the first run. Rotating the key on the share re-converges the fleet on +# the next cycle with no other action. +# +# Always exits 0 so the GE-Enforce "last run result" stays clean. + +param( + # Override for a one-off run; normally read from the sibling config file. + [string]$CollectorKey, + [string]$BaseUrl +) + +$ErrorActionPreference = 'Continue' + +$SHOPDBREG = 'HKLM:\SOFTWARE\GE\ShopDB' +$KEYCONFIG = Join-Path $PSScriptRoot '..\configs\shopdb-collector.txt' +$LOGDIR = 'C:\Logs\Shopfloor' +$LOGFILE = Join-Path $LOGDIR 'shopdb-collector-key.log' + +if (-not (Test-Path $LOGDIR)) { + New-Item -ItemType Directory -Path $LOGDIR -Force -EA SilentlyContinue | Out-Null +} + +function Log { + param([string]$Message) + $line = '[{0}] {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message + Add-Content -Path $LOGFILE -Value $line -EA SilentlyContinue + Write-Host $line +} + +function Read-KeyConfig { + <# + LABELLED lines only: + baseurl=https://host/shopdb + collector= + A bare line is ignored rather than guessed at - a fetch token landing in + the collector slot would leave reporting broken while looking configured. + #> + $result = @{ BaseUrl = ''; CollectorKey = '' } + if (-not (Test-Path $KEYCONFIG)) { return $result } + foreach ($line in (Get-Content $KEYCONFIG -EA SilentlyContinue)) { + $t = $line.Trim() + if (-not $t -or $t.StartsWith('#')) { continue } + if ($t -match '^(?i)collector\s*=\s*(.+)$') { $result.CollectorKey = $Matches[1].Trim() } + elseif ($t -match '^(?i)baseurl\s*=\s*(.+)$') { $result.BaseUrl = $Matches[1].Trim().TrimEnd('/') } + else { Log " ignoring unlabelled line in $(Split-Path $KEYCONFIG -Leaf)" } + } + return $result +} + +Log '=== Set-ShopdbCollectorKey start ===' + +$cfg = Read-KeyConfig +if (-not $CollectorKey) { $CollectorKey = $cfg.CollectorKey } +if (-not $BaseUrl) { $BaseUrl = $cfg.BaseUrl } + +if (-not $CollectorKey) { + # Not an error: an unconfigured share is the normal state before a site + # issues its token. Say exactly what to do rather than failing silently. + Log "No collector key configured. Put a 'collector=' line in:" + Log " $KEYCONFIG" + Log "Nothing written." + exit 0 +} + +# --- compare before writing ------------------------------------------------ +$current = $null +try { $current = Get-ItemProperty -Path $SHOPDBREG -EA Stop } catch { } + +$needKey = (-not $current) -or ($current.CollectorKey -ne $CollectorKey) +$needUrl = $BaseUrl -and ((-not $current) -or ($current.BaseUrl -ne $BaseUrl)) + +if (-not $needKey -and -not $needUrl) { + Log 'Registry already matches - nothing to do.' + Log '=== Set-ShopdbCollectorKey end ===' + exit 0 +} + +try { + if (-not (Test-Path $SHOPDBREG)) { New-Item -Path $SHOPDBREG -Force | Out-Null } + + if ($needUrl) { + New-ItemProperty -Path $SHOPDBREG -Name BaseUrl -Value $BaseUrl ` + -PropertyType String -Force -EA Stop | Out-Null + Log "Set BaseUrl = $BaseUrl" + } + if ($needKey) { + New-ItemProperty -Path $SHOPDBREG -Name CollectorKey -Value $CollectorKey ` + -PropertyType String -Force -EA Stop | Out-Null + Log "Set CollectorKey (length $($CollectorKey.Length)) - value not logged" + } + + # Lock the key down to SYSTEM + Administrators, matching what + # Install-ShopdbKiosk does on displays. Without this the value is readable + # by any interactive user, and a shopfloor PC is a shared login. + try { + $acl = Get-Acl $SHOPDBREG + $acl.SetAccessRuleProtection($true, $false) + foreach ($who in 'SYSTEM', 'Administrators') { + $acl.AddAccessRule((New-Object Security.AccessControl.RegistryAccessRule( + $who, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + } + Set-Acl -Path $SHOPDBREG -AclObject $acl -EA Stop + Log 'ACL set: SYSTEM + Administrators only.' + } catch { + Log "WARNING - could not tighten the ACL: $_" + } +} catch { + Log "FAILED to write ${SHOPDBREG}: $_" +} + +Log '=== Set-ShopdbCollectorKey end ===' +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd b/playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd new file mode 100755 index 0000000..b2eac8f --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Setup-OpenText.cmd @@ -0,0 +1,15 @@ +@echo off +REM Setup-OpenText.cmd - launcher for Setup-OpenText.ps1. +REM +REM Lives in /scripts/. Setup-OpenText.ps1 expects to find the four +REM OpenText binaries (OpenTextHostExplorer15x64.msi/.cab/.msp + ShopFloorx64.mst) +REM plus version.txt in $SourceDir. We pass ..\apps\opentext\ explicitly so the +REM script doesn't fall back to its $PSScriptRoot default (which would be the +REM scripts/ dir, where the binaries don't live). +REM +REM Mirrors the Install-AcroReader.cmd / Install-Oracle11r2.cmd pattern of +REM script-in-scripts, payload-in-apps. Called by Install-FromManifest.ps1 +REM (Type=CMD); exit code surfaces back to the enforcer. + +powershell -NoProfile -ExecutionPolicy Bypass -File "%~dp0Setup-OpenText.ps1" -SourceDir "%~dp0..\apps\opentext" +exit /b %errorlevel% diff --git a/playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1 b/playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1 new file mode 100755 index 0000000..1dbd8f4 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Setup-OpenText.ps1 @@ -0,0 +1,361 @@ +#!/usr/bin/env pwsh +# Setup-OpenText.ps1 - OpenText HostExplorer 15 SP1 ShopFloor installer + profile +# deployment, callable from both PXE PreInstall and Intune DSC paths. +# +# WHY THIS EXISTS: +# The vendor-supplied OpenText.exe (Inno Setup wrapper built by WJDT) bundles +# the install steps but its [Files] section deploys per-user content to +# {userappdata} - which resolves to SYSTEM's profile under DSC and to a single +# user under PreInstall. As a result the operator (logging in via Azure AD) +# never sees the profiles, keymaps, menus, or macros - only the installed +# binaries. This script replaces OpenText.exe entirely, doing the same install +# steps via direct msiexec calls AND fanning the per-user content out to: +# - %ProgramData%\Hummingbird\Connectivity\15.00\Shared\ +# - C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00\ +# - Each existing user profile under C:\Users\ +# +# INVOKED BY: +# - PreInstall: Setup-OpenText.cmd wrapper (because the runner only knows MSI/EXE) +# - DSC: Install-OpenText.ps1 downloads the bundled tree from blob, then +# invokes this script with -SourceDir +# +# DETECTION: +# Skips if HKLM:\SOFTWARE\GE\OpenText\Installed = $expectedVersion. Marker is +# written at the end of a successful run, so the runner / DSC wrapper can +# no-op on subsequent invocations. + +[CmdletBinding()] +param( + # Override when invoked from a temp dir (DSC path) where the bundled files were + # just downloaded. Defaults to $PSScriptRoot, but resolved INSIDE the script body + # below - PowerShell evaluates `param([string]$X = $PSScriptRoot)` at parameter- + # binding time, when $PSScriptRoot may not yet be populated, so the default winds + # up as an empty string. Setting it in the body works because $PSScriptRoot is + # reliably populated by then. + [string]$SourceDir +) + +$ErrorActionPreference = 'Stop' + +if (-not $SourceDir) { + $SourceDir = $PSScriptRoot +} + +# Normalize $SourceDir to a canonical absolute path. The CMD shim passes +# "%~dp0..\apps\opentext" which embeds a literal "..". msiexec / the Windows +# Installer service fail to open the package with that unresolved segment +# (exit 1619, ERROR_INSTALL_PACKAGE_OPEN_FAILED), even though every other +# .NET / PowerShell API resolves it fine. Resolve-Path collapses ".." into +# a clean drive-rooted path before any msiexec invocation. +if (Test-Path -LiteralPath $SourceDir) { + try { $SourceDir = (Resolve-Path -LiteralPath $SourceDir).ProviderPath } catch {} +} + +# --- Inline site-config reader (this script runs from C:\PreInstall\installers\opentext\, +# NOT from C:\Enrollment\shopfloor-setup\, so it can't dot-source Get-PCProfile.ps1) --- +function Get-SiteConfig { + $configPath = 'C:\Enrollment\site-config.json' + if (-not (Test-Path $configPath)) { return $null } + try { + return Get-Content $configPath -Raw | ConvertFrom-Json + } catch { + return $null + } +} + +# --- Logging (set up FIRST so any startup error - missing version.txt, broken +# bundled file, etc. - lands in the log file instead of disappearing into the +# runner's stdout void) --- +$logDir = 'C:\Logs\PreInstall' +$logFile = Join-Path $logDir 'Setup-OpenText.log' +$msiLog = Join-Path $logDir 'Setup-OpenText-msi.log' +$mspLog = Join-Path $logDir 'Setup-OpenText-msp.log' + +if (-not (Test-Path $logDir)) { + New-Item -Path $logDir -ItemType Directory -Force | Out-Null +} + +function Write-SetupLog { + param([string]$Message) + $line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] $Message" + Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue + Write-Host $line +} + +Write-SetupLog "================================================================" +Write-SetupLog "=== Setup-OpenText.ps1 starting ===" +Write-SetupLog "================================================================" +Write-SetupLog "SourceDir: $SourceDir" +Write-SetupLog "PSScriptRoot: $PSScriptRoot" +Write-SetupLog "Running as: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name)" + +# --- Config --- +# Version is read from version.txt next to this script. ONE source of truth: bumping +# version.txt is the only edit needed when shipping a new OpenText build. Setup- +# OpenText.ps1 itself, Install-OpenText.ps1 (DSC wrapper), and the registry marker +# all derive their notion of "expected version" from this file. +$versionFile = Join-Path $SourceDir 'version.txt' +Write-SetupLog "Looking for version.txt at: $versionFile" +if (-not (Test-Path $versionFile)) { + Write-SetupLog "ERROR: version.txt not found in $SourceDir - cannot determine expected version." + Write-SetupLog "Directory listing of $SourceDir :" + if (Test-Path $SourceDir) { + Get-ChildItem $SourceDir -ErrorAction SilentlyContinue | ForEach-Object { + Write-SetupLog " $($_.Name) $($_.Length) bytes" + } + } else { + Write-SetupLog " (SourceDir does not exist)" + } + exit 1 +} +$expectedVersion = (Get-Content -Path $versionFile -Raw -ErrorAction Stop).Trim() +if (-not $expectedVersion) { + Write-SetupLog "ERROR: version.txt at $versionFile is empty." + exit 1 +} +Write-SetupLog "Expected version (from version.txt): $expectedVersion" + +# --- Detection: skip if already deployed at expected version --- +$markerKey = 'HKLM:\SOFTWARE\GE\OpenText' +$markerVal = 'Installed' +if (Test-Path $markerKey) { + $installed = (Get-ItemProperty -Path $markerKey -Name $markerVal -ErrorAction SilentlyContinue).$markerVal + if ($installed -eq $expectedVersion) { + Write-SetupLog "OpenText $expectedVersion already deployed (marker present) - skipping." + exit 0 + } + Write-SetupLog "OpenText marker mismatch (found '$installed', expected '$expectedVersion') - re-deploying." +} +else { + Write-SetupLog "OpenText marker not present - first install." +} + +# --- Verify bundled files are where we expect --- +$msiPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.msi' +$cabPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64.cab' +$mspPath = Join-Path $SourceDir 'OpenTextHostExplorer15x64_ServicePack1.msp' +$mstPath = Join-Path $SourceDir 'ShopFloorx64.mst' + +foreach ($f in @($msiPath, $cabPath, $mspPath, $mstPath)) { + if (-not (Test-Path $f)) { + Write-SetupLog "ERROR: required file not found: $f" + exit 1 + } +} + +# --- Step 1: Install the base MSI with the ShopFloor transform --- +# NOTE: We deliberately do NOT pass REBOOT=ReallySuppress here even though we do +# for the VC++ MSIs. OpenText HostExplorer installs shell extensions that hook +# explorer.exe, and the MSI uses Restart Manager to ask explorer to close so the +# in-use shell DLLs can be replaced. With REBOOT=ReallySuppress, RM closes +# explorer.exe but interprets "restart explorer" as a reboot action and refuses +# to relaunch it - leaving the user without a desktop. /norestart on its own +# prevents the actual Windows reboot but lets RM cleanly close-and-relaunch +# explorer mid-install. msiexec still returns 3010 ("reboot would be needed"), +# which we treat as success below. +Write-SetupLog "" +Write-SetupLog "Step 1: Installing OpenTextHostExplorer15x64.msi with ShopFloorx64.mst..." +if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue } + +$msiArgs = "/i `"$msiPath`" TRANSFORMS=`"$mstPath`" /qn /norestart /L*v `"$msiLog`"" +Write-SetupLog " msiexec.exe $msiArgs" + +$psi = New-Object System.Diagnostics.ProcessStartInfo +$psi.FileName = 'msiexec.exe' +$psi.Arguments = $msiArgs +$psi.UseShellExecute = $false +$psi.CreateNoWindow = $true +$proc = [System.Diagnostics.Process]::Start($psi) +$proc.WaitForExit() +$msiExit = $proc.ExitCode +Write-SetupLog " msiexec exit code: $msiExit" + +if ($msiExit -ne 0 -and $msiExit -ne 3010) { + Write-SetupLog "ERROR: base MSI install failed (exit $msiExit). See $msiLog" + exit 1 +} +if ($msiExit -eq 3010) { + Write-SetupLog " (3010 = reboot needed but suppressed)" +} + +# --- Step 2: Apply Service Pack 1 patch --- +# Same Restart Manager rationale as Step 1 - skip REBOOT=ReallySuppress so RM +# can relaunch explorer.exe after replacing the patched shell extension DLLs. +Write-SetupLog "" +Write-SetupLog "Step 2: Applying SP1 patch..." +if (Test-Path $mspLog) { Remove-Item $mspLog -Force -ErrorAction SilentlyContinue } + +$mspArgs = "/p `"$mspPath`" /qn /norestart /L*v `"$mspLog`"" +Write-SetupLog " msiexec.exe $mspArgs" + +$psi.Arguments = $mspArgs +$proc = [System.Diagnostics.Process]::Start($psi) +$proc.WaitForExit() +$mspExit = $proc.ExitCode +Write-SetupLog " msiexec exit code: $mspExit" + +if ($mspExit -ne 0 -and $mspExit -ne 3010) { + Write-SetupLog "ERROR: SP1 patch failed (exit $mspExit). See $mspLog" + exit 1 +} + +# --- Step 3: Deploy profiles, keymaps, menus, accessories --- +# Source layout (bundled with this script): +# $SourceDir\Profile\*.hep +# $SourceDir\Accessories\EB\*.eb* +# $SourceDir\HostExplorer\Keymap\*.kmv +# $SourceDir\HostExplorer\Menu\*.hmv +# +# Target layouts (Hummingbird-canonical): +# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Profile\ +# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\Accessories\EB\ +# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Keymap\ +# %ProgramData%\Hummingbird\Connectivity\15.00\Shared\HostExplorer\Menu\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\Profile\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\Accessories\EB\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Keymap\ +# \AppData\Roaming\Hummingbird\Connectivity\15.00\HostExplorer\Menu\ +# +# We deploy to ProgramData\Shared (system-wide fallback), Default User (template +# inherited by every NEW user profile), and every existing user profile (so +# already-created accounts like SupportUser get them immediately). + +Write-SetupLog "" +Write-SetupLog "Step 3: Deploying profiles/keymaps/menus/macros..." + +# --- Resolve exclude lists from site-config.json (falls back to West Jefferson defaults) --- +$siteConfig = Get-SiteConfig +$profileExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeProfiles) { + @($siteConfig.opentext.excludeProfiles) +} else { + @('WJ_Office.hep', 'IBM_qks.hep', 'mmcs.hep') # West Jefferson defaults +} +$shortcutExcludes = if ($siteConfig -and $siteConfig.opentext -and $siteConfig.opentext.excludeShortcuts) { + @($siteConfig.opentext.excludeShortcuts) +} else { + @('WJ_Office.lnk', 'IBM_qks.lnk', 'mmcs.lnk') +} +if ($siteConfig) { + Write-SetupLog "Site config loaded - profile excludes: $($profileExcludes -join ', ')" + Write-SetupLog "Site config loaded - shortcut excludes: $($shortcutExcludes -join ', ')" +} else { + Write-SetupLog "No site-config.json found - using West Jefferson defaults for excludes" +} + +# Map of source subdir -> destination subdir relative to the Hummingbird root. +# Optional Exclude list drops specific filenames from both the source-to-dest +# copy AND from the destination if they were left over from a prior install. +$contentMap = @( + @{ + Src = 'Profile' + Dst = 'Profile' + Exclude = $profileExcludes + } + @{ Src = 'Accessories\EB'; Dst = 'Accessories\EB' } + @{ Src = 'HostExplorer\Keymap'; Dst = 'HostExplorer\Keymap' } + @{ Src = 'HostExplorer\Menu'; Dst = 'HostExplorer\Menu' } +) + +function Copy-HummingbirdContent { + param( + [string]$RootDst, # Hummingbird root, e.g. C:\ProgramData\Hummingbird\Connectivity\15.00\Shared + [string]$Label + ) + foreach ($entry in $contentMap) { + $srcPath = Join-Path $SourceDir $entry.Src + if (-not (Test-Path $srcPath)) { continue } + $dstPath = Join-Path $RootDst $entry.Dst + New-Item -Path $dstPath -ItemType Directory -Force | Out-Null + + # Remove any previously-deployed excluded files from the destination + # - handles the case where a PC got them from an older install. + if ($entry.Exclude) { + foreach ($name in $entry.Exclude) { + $stale = Join-Path $dstPath $name + if (Test-Path -LiteralPath $stale) { + try { + Remove-Item -LiteralPath $stale -Force -ErrorAction Stop + Write-SetupLog " $Label : removed stale $name" + } catch { + Write-SetupLog " $Label : failed to remove $stale : $_" + } + } + } + } + + $files = Get-ChildItem -Path $srcPath -File -ErrorAction SilentlyContinue + if ($entry.Exclude) { + $files = @($files | Where-Object { $entry.Exclude -notcontains $_.Name }) + } + foreach ($f in $files) { + Copy-Item -Path $f.FullName -Destination $dstPath -Force + } + Write-SetupLog " $Label : $($entry.Src) -> $dstPath ($($files.Count) files)" + } +} + +# 3a. ProgramData Shared +$sharedRoot = Join-Path $env:ProgramData 'Hummingbird\Connectivity\15.00\Shared' +Copy-HummingbirdContent -RootDst $sharedRoot -Label 'Shared' + +# 3b. Default User (template for new user profiles) +$defaultUserRoot = 'C:\Users\Default\AppData\Roaming\Hummingbird\Connectivity\15.00' +Copy-HummingbirdContent -RootDst $defaultUserRoot -Label 'Default User' + +# 3c. Every existing user profile under C:\Users\ +$skipNames = @('Default', 'Default User', 'Public', 'defaultuser0', 'All Users', 'WDAGUtilityAccount') +$userDirs = Get-ChildItem 'C:\Users' -Directory -ErrorAction SilentlyContinue | + Where-Object { $skipNames -notcontains $_.Name -and (Test-Path "$($_.FullName)\AppData\Roaming") } + +foreach ($u in $userDirs) { + $userRoot = Join-Path $u.FullName 'AppData\Roaming\Hummingbird\Connectivity\15.00' + Copy-HummingbirdContent -RootDst $userRoot -Label $u.Name +} + +# --- Step 4: Public Desktop shortcuts --- +# Uses $shortcutExcludes (resolved from site-config.json above) to skip +# deploying unwanted .lnk files AND remove any a prior install left behind. +Write-SetupLog "" +Write-SetupLog "Step 4: Deploying public desktop shortcuts..." +$shortcutSrc = Join-Path $SourceDir 'W10shortcuts' +$publicDesktop = 'C:\Users\Public\Desktop' + +# Clean up stale copies from prior installs first +foreach ($name in $shortcutExcludes) { + $stale = Join-Path $publicDesktop $name + if (Test-Path -LiteralPath $stale) { + try { + Remove-Item -LiteralPath $stale -Force -ErrorAction Stop + Write-SetupLog " removed stale desktop shortcut: $name" + } catch { + Write-SetupLog " failed to remove stale $stale : $_" + } + } +} + +if (Test-Path $shortcutSrc) { + $lnkFiles = Get-ChildItem -Path $shortcutSrc -Filter '*.lnk' -File -ErrorAction SilentlyContinue + foreach ($l in $lnkFiles) { + if ($shortcutExcludes -contains $l.Name) { + Write-SetupLog " skip (excluded): $($l.Name)" + continue + } + Copy-Item -Path $l.FullName -Destination $publicDesktop -Force + Write-SetupLog " $($l.Name) -> $publicDesktop" + } +} + +# --- Step 5: Write registry marker --- +Write-SetupLog "" +Write-SetupLog "Step 5: Writing registry marker..." +if (-not (Test-Path $markerKey)) { + New-Item -Path $markerKey -Force | Out-Null +} +Set-ItemProperty -Path $markerKey -Name $markerVal -Value $expectedVersion -Force +Set-ItemProperty -Path $markerKey -Name 'InstalledAt' -Value (Get-Date -Format 'o') -Force + +Write-SetupLog "" +Write-SetupLog "================================================================" +Write-SetupLog "=== OpenText HostExplorer ShopFloor $expectedVersion deployed ===" +Write-SetupLog "================================================================" +exit 0 diff --git a/playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1 b/playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1 new file mode 100644 index 0000000..a37c884 --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/Test-RegExport.ps1 @@ -0,0 +1,82 @@ +# Test-RegExport.ps1 +# +# Exercises Backup-NtlarsSettings.ps1's Export-DncToReg against MOCK registry +# keys, so the formatting can be verified on a machine with no registry (a +# Linux dev box running PowerShell Core, for instance). +# +# Why this exists: bad escaping or a wrong dword format produces a .reg that +# looks fine, imports without complaint, and only reveals itself when a tech +# restores a machine and the settings are subtly wrong. That failure is far too +# late and far too expensive, so the formatting gets tested away from the bay. +# +# Writes the generated .reg to -OutFile so the ShopDB-side Python codec can +# parse it in the same run and confirm both ends agree. +# +# pwsh -NoProfile -File Test-RegExport.ps1 -OutFile /tmp/mock.reg + +param( + [string]$OutFile = './mock-export.reg' +) + +$ErrorActionPreference = 'Stop' + +. (Join-Path $PSScriptRoot 'Backup-NtlarsSettings.ps1') + +function New-MockKey { + <# + A stand-in for a Microsoft.Win32.RegistryKey. PowerShell is duck-typed, + so Export-DncToReg only needs .Name, .GetValueNames(), .GetValueKind() + and .GetValue(). + #> + param( + [Parameter(Mandatory)][string]$Name, + [Parameter(Mandatory)][hashtable]$Values # name -> @{ Kind; Data } + ) + $key = [pscustomobject]@{ Name = $Name; _values = $Values } + $key | Add-Member ScriptMethod GetValueNames { $this._values.Keys } -Force + $key | Add-Member ScriptMethod GetValueKind { + param($n) $this._values[$n].Kind } -Force + $key | Add-Member ScriptMethod GetValue { + param($n) $this._values[$n].Data } -Force + return $key +} + +$root = 'HKEY_LOCAL_MACHINE\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC' + +$keys = @( + New-MockKey -Name $root -Values @{ + 'COMPUTERNAME' = @{ Kind = 'String'; Data = 'GGBX0NH3ESF' } + } + New-MockKey -Name "$root\General" -Values @{ + 'MachineNo' = @{ Kind = 'String'; Data = '3204' } + 'Cnc' = @{ Kind = 'String'; Data = 'OKUMA' } + 'HostType' = @{ Kind = 'String'; Data = 'WILM' } + } + New-MockKey -Name "$root\Btr" -Values @{ + 'BTR Rate' = @{ Kind = 'String'; Data = '300' } + 'Auto Rewind' = @{ Kind = 'String'; Data = 'YES' } + 'CmntLag' = @{ Kind = 'DWord'; Data = 0 } + 'BigCount' = @{ Kind = 'DWord'; Data = 4294967295 } + } + # The nasty cases: characters that must be escaped, and the wide/binary + # value kinds that do not occur in the current corpus but are legal. + New-MockKey -Name "$root\EdgeCases" -Values @{ + 'Path With Backslash' = @{ Kind = 'String'; Data = 'C:\Program Files\GE' } + 'Has "Quotes"' = @{ Kind = 'String'; Data = 'say "hi"' } + 'Empty' = @{ Kind = 'String'; Data = '' } + 'Expanded' = @{ Kind = 'ExpandString'; Data = '%SystemRoot%\dnc' } + 'Multi' = @{ Kind = 'MultiString'; Data = @('one','two') } + 'Blob' = @{ Kind = 'Binary'; Data = [byte[]](1,2,255) } + 'Big' = @{ Kind = 'QWord'; Data = [uint64]1234567890123 } + } +) + +$text = Export-DncToReg -Keys $keys + +# UTF-16LE + BOM, matching what the real script posts. +$bytes = [byte[]](0xFF, 0xFE) + [Text.Encoding]::Unicode.GetBytes($text) +[IO.File]::WriteAllBytes($OutFile, $bytes) + +Write-Host "Wrote $OutFile ($($bytes.Length) bytes)" +Write-Host '--- first lines ---' +($text -split "`r`n" | Select-Object -First 12) | ForEach-Object { Write-Host " $_" } diff --git a/playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1 b/playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1 new file mode 100755 index 0000000..d3153ee --- /dev/null +++ b/playbook/shopfloor-setup/common/scripts/ensure-vnc-firewall.ps1 @@ -0,0 +1,37 @@ +# ensure-vnc-firewall.ps1 +# Idempotent inbound firewall rules for VNC port 5900 on all network profiles. +# Called by Install-FromManifest with Type=PS1, DetectionMethod=Always (runs +# every enforcement cycle; the Remove + New pattern makes repeat runs cheap +# and always end in a known-good state). +# +# Exit 0 on success, 1 on failure. SYSTEM context. + +$ErrorActionPreference = 'Continue' + +$rules = @( + @{ Name = 'GE Shopfloor VNC 5900 TCP'; Protocol = 'TCP'; LocalPort = 5900 } + @{ Name = 'GE Shopfloor VNC 5900 UDP'; Protocol = 'UDP'; LocalPort = 5900 } +) + +$failed = 0 +foreach ($r in $rules) { + try { + Remove-NetFirewallRule -DisplayName $r.Name -ErrorAction SilentlyContinue + New-NetFirewallRule ` + -DisplayName $r.Name ` + -Direction Inbound ` + -Protocol $r.Protocol ` + -LocalPort $r.LocalPort ` + -Action Allow ` + -Profile Domain,Private,Public ` + -Description 'VNC remote access for shopfloor ops (5900). Managed by GE Shopfloor Enforce.' ` + -ErrorAction Stop | Out-Null + Write-Host "[OK] $($r.Name) ($($r.Protocol) $($r.LocalPort))" + } catch { + Write-Host "[FAIL] $($r.Name): $_" + $failed++ + } +} + +if ($failed -gt 0) { exit 1 } +exit 0