GE-Enforce mounts the SFLD share on W: and holds it for the whole enforce cycle, passing W:\<pctype> as -InstallerRoot to Install-FromManifest. Two scripts that run inside that cycle, as SYSTEM in the same drive namespace, mapped W: for their own use and deleted it on exit: Restore-UDCData.ps1 manifest PS1 entry, mounts the UDC backup share Update-MachineNumber.ps1 "Apply Machine Number" task, same backup share Once W: is gone, the next manifest entry's Join-Path resolves against a dead drive qualifier. In Windows PowerShell 5.1 that emits nothing rather than throwing, so the null lands in Test-Path -LiteralPath and the entry dies with "Cannot bind argument to parameter 'LiteralPath' because it is null". Observed on a collections bay: the controller-credential entry, which runs immediately after UDC Data Restore, failed this way while the entry one line earlier had resolved a W: path successfully one second before. Restore-UDCData now uses R: and Update-MachineNumber uses N: for the UDC backup share. Neither letter is referenced anywhere else in the tree; V: was not an option because Restore-EDncReg and Update-MachineNumber's NTLARS restore already use it. The re-mount at GE-Enforce.ps1:239-244 does not cover this. It runs after the manifest loop and blames idle SMB timeout, but the whole cycle took one second, so timeout was never the cause.
391 lines
17 KiB
PowerShell
391 lines
17 KiB
PowerShell
# Restore-UDCData.ps1 - Idempotent UDC data restore for the manifest engine.
|
|
#
|
|
# Triggered by the GE Shopfloor Enforce scheduled task (runs as SYSTEM, every
|
|
# user logon + every 5 min). Standard-machine manifest entry uses
|
|
# DetectionMethod=Always so this fires every cycle; the script self-decides
|
|
# whether there's actually any work to do.
|
|
#
|
|
# CONTRACT:
|
|
# - 99% of cycles: no backup waiting -> exit 0 in ~1 second, ~5 lines of log
|
|
# - 1 cycle (the one after Backup-UDCData lands a backup for this PC's bay):
|
|
# stop UDC, copy CurrentData.json + ArchivedData/ to C:\ProgramData\UDC,
|
|
# move consumed backup to <bay>\migrated\<timestamp>\, write
|
|
# restore.manifest.json, restart UDC. After this, root is empty so the
|
|
# check returns "no backup waiting" again on subsequent cycles.
|
|
#
|
|
# DESIGNED FOR THE SWAP WORKFLOW:
|
|
# New PC gets pre-imaged with real machine number + locked down, sits in
|
|
# storage. Days/weeks later, tech runs Backup-UDCData on old PC -> backup
|
|
# lands on share. Tech swaps PCs. New PC powers on at the bay -> ShopFloor
|
|
# autologon -> manifest engine fires this script -> backup detected ->
|
|
# restored -> UDC opens with prior history intact.
|
|
#
|
|
# Replaces the placeholder->real trigger in Update-MachineNumber.ps1 for the
|
|
# pre-imaged-then-swapped case (where the trigger fired at imaging time, before
|
|
# the backup existed). Update-MachineNumber.ps1's branch still handles the
|
|
# secondary case (tech used 9999 placeholder + sets number at bay) - both
|
|
# triggers safely no-op if the other already consumed the backup.
|
|
#
|
|
# LOGGING:
|
|
# Single rotating log at C:\Logs\UDC\Restore-UDCData.log (1 MB cap, rotated
|
|
# to .old.log on overflow). Every cycle writes a header line so even the
|
|
# silent no-op path leaves a trace. Errors include full exception type,
|
|
# position, and stack trace.
|
|
|
|
[CmdletBinding()]
|
|
param(
|
|
[string]$BackupShareRoot = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backup\udc',
|
|
[string]$UdcDataDir = 'C:\ProgramData\UDC',
|
|
[string]$UdcExePath = 'C:\Program Files\UDC\UDC.exe',
|
|
[string]$UdcSettingsPath = 'C:\ProgramData\UDC\udc_settings.json',
|
|
[string]$Site = 'West Jefferson',
|
|
# Share can take 20-60s to become reachable from SYSTEM context after a
|
|
# cold boot or fresh logon. Retry until then before deciding "no backup".
|
|
[int]$ShareTimeoutSec = 60,
|
|
[int]$SharePollSec = 3
|
|
)
|
|
|
|
$ErrorActionPreference = 'Continue'
|
|
|
|
# -- Logging setup --------------------------------------------------------
|
|
$logDir = 'C:\Logs\UDC'
|
|
try {
|
|
if (-not (Test-Path $logDir)) { New-Item -Path $logDir -ItemType Directory -Force | Out-Null }
|
|
} catch { $logDir = $env:TEMP }
|
|
$logFile = Join-Path $logDir 'Restore-UDCData.log'
|
|
$logFileMaxBytes = 1MB
|
|
|
|
# Rotate log file if oversized (keeps one prior generation)
|
|
try {
|
|
if ((Test-Path $logFile) -and ((Get-Item $logFile).Length -gt $logFileMaxBytes)) {
|
|
$rotated = Join-Path $logDir 'Restore-UDCData.old.log'
|
|
if (Test-Path $rotated) { Remove-Item $rotated -Force -ErrorAction SilentlyContinue }
|
|
Rename-Item -Path $logFile -NewName 'Restore-UDCData.old.log' -Force -ErrorAction SilentlyContinue
|
|
}
|
|
} catch {}
|
|
|
|
function Log {
|
|
param([string]$Msg, [string]$Level = 'INFO')
|
|
$ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss.fff'
|
|
$line = "[$ts][$Level] $Msg"
|
|
try { Add-Content -LiteralPath $logFile -Value $line -ErrorAction SilentlyContinue } catch {}
|
|
Write-Host $line
|
|
}
|
|
|
|
function LogErr {
|
|
param($Err)
|
|
if (-not $Err) { return }
|
|
$exType = if ($Err.Exception) { $Err.Exception.GetType().FullName } else { '<no exception>' }
|
|
$exMsg = if ($Err.Exception) { $Err.Exception.Message } else { "$Err" }
|
|
Log " exception: $exType - $exMsg" 'ERROR'
|
|
if ($Err.InvocationInfo -and $Err.InvocationInfo.PositionMessage) {
|
|
$pos = ($Err.InvocationInfo.PositionMessage -replace "`r?`n", ' | ')
|
|
Log " at: $pos" 'ERROR'
|
|
}
|
|
if ($Err.ScriptStackTrace) {
|
|
$st = ($Err.ScriptStackTrace -replace "`r?`n", ' | ')
|
|
Log " stack: $st" 'ERROR'
|
|
}
|
|
if ($Err.Exception -and $Err.Exception.InnerException) {
|
|
Log " inner: $($Err.Exception.InnerException.Message)" 'ERROR'
|
|
}
|
|
}
|
|
|
|
Log '==============================================='
|
|
Log "Restore-UDCData starting (PID $PID)"
|
|
Log "Hostname: $env:COMPUTERNAME"
|
|
try {
|
|
$whoami = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name
|
|
} catch { $whoami = '<unknown>' }
|
|
Log "User identity: $whoami"
|
|
Log "PowerShell version: $($PSVersionTable.PSVersion)"
|
|
Log "BackupShareRoot: $BackupShareRoot"
|
|
Log "UdcDataDir: $UdcDataDir"
|
|
Log "UdcSettingsPath: $UdcSettingsPath"
|
|
Log "ShareTimeoutSec: $ShareTimeoutSec SharePollSec: $SharePollSec"
|
|
|
|
# -- Resolve local machine number ----------------------------------------
|
|
if (-not (Test-Path -LiteralPath $UdcSettingsPath)) {
|
|
Log "udc_settings.json not present - UDC not installed yet, no work to do."
|
|
Log 'Exit 0.'
|
|
exit 0
|
|
}
|
|
try {
|
|
$json = Get-Content -LiteralPath $UdcSettingsPath -Raw -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
|
|
$mn = $json.GeneralSettings.MachineNumber
|
|
Log "Resolved MachineNumber from udc_settings: $mn"
|
|
} catch {
|
|
Log "Failed to parse $UdcSettingsPath" 'ERROR'
|
|
LogErr $_
|
|
Log 'Exit 0.'
|
|
exit 0
|
|
}
|
|
if (-not $mn -or $mn -eq '9999' -or $mn -notmatch '^\d+$') {
|
|
Log "Machine number is placeholder/empty/non-numeric ('$mn'). Update-MachineNumber.ps1's branch will catch the placeholder->real transition. No work to do."
|
|
Log 'Exit 0.'
|
|
exit 0
|
|
}
|
|
|
|
# -- Mount share with SFLD user creds -----------------------------------
|
|
# This script runs as NT AUTHORITY\SYSTEM (manifest engine on logon, or
|
|
# scheduled task). SYSTEM authenticates to remote SMB as the COMPUTER
|
|
# ACCOUNT (DOMAIN\HOSTNAME$), not as a user. The SFLD share's ACLs grant
|
|
# top-level enumeration to authenticated computers but file-level read
|
|
# only to a specific SFLD user. Without explicit user creds, Test-Path
|
|
# on bay-level files returns False (access denied = same return as not-
|
|
# found), making the script silently log "absent" when the files in fact
|
|
# exist. Symptom: Restore-UDCData.log shows endless "no work this cycle"
|
|
# while another PC (or a user-context invocation) successfully consumes
|
|
# the backup. Fix: mount the share with explicit SFLD creds from
|
|
# HKLM:\SOFTWARE\GE\SFLD\Credentials and probe via the drive letter.
|
|
# Inline Mount-SFLDShare so this script can run from any location -
|
|
# specifically the SFLD share path \\tsgwp00525...\standard-machine\scripts\
|
|
# where GE-Enforce executes it. The original Shopfloor\lib\Restore-EDncReg.ps1
|
|
# is only present in the C:\Enrollment\ imaging-time tree, not on the share,
|
|
# so dot-sourcing its relative path would silently fail and Mount-SFLDShare
|
|
# would be undefined at call time.
|
|
function Mount-SFLDShare {
|
|
param(
|
|
[Parameter(Mandatory)][string]$SharePath,
|
|
[string]$DriveLetter = 'R:'
|
|
)
|
|
$server = ($SharePath -replace '^\\\\', '') -split '\\' | Select-Object -First 1
|
|
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
|
|
if (-not (Test-Path $basePath)) { return $false }
|
|
$cred = $null
|
|
foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) {
|
|
$props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
|
|
if (-not $props) { continue }
|
|
# Match either the value-style TargetHost (test-fixture) or the
|
|
# production-style layout where the credential subkey NAME is the host.
|
|
$hosts = @()
|
|
if ($props.TargetHost) { $hosts += $props.TargetHost }
|
|
$hosts += $entry.PSChildName
|
|
$matched = $false
|
|
foreach ($h in $hosts) {
|
|
if (-not $h) { continue }
|
|
if ($h -eq $server -or $h -like "$server.*" -or $server -like "$h.*") {
|
|
$matched = $true; break
|
|
}
|
|
}
|
|
if ($matched -and $props.Username -and $props.Password) {
|
|
$cred = $props; break
|
|
}
|
|
}
|
|
if (-not $cred) { return $false }
|
|
& net use $DriveLetter /delete /y 2>$null | Out-Null
|
|
$null = & net use $DriveLetter $SharePath /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1
|
|
return ($LASTEXITCODE -eq 0)
|
|
}
|
|
|
|
# R:, not W: and not V:. GE-Enforce owns W: for the whole enforce cycle
|
|
# (GE-Enforce.ps1 $driveLetter). This script runs as a manifest PS1 entry
|
|
# inside that cycle, so remapping/deleting W: here kills the share for every
|
|
# later manifest entry - Join-Path on a dead W: returns null and the next
|
|
# entry dies with "Cannot bind argument to parameter 'LiteralPath' because it
|
|
# is null". V: is no good either: Update-MachineNumber.ps1 / Restore-EDncReg.ps1
|
|
# mount V: and run as SYSTEM in the same drive namespace. R: is unclaimed.
|
|
Log "Mounting share with SFLD creds: $BackupShareRoot -> R:"
|
|
$shareMounted = $false
|
|
$sw = [Diagnostics.Stopwatch]::StartNew()
|
|
while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
|
|
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'R:') {
|
|
$shareMounted = $true
|
|
break
|
|
}
|
|
Start-Sleep -Seconds $SharePollSec
|
|
}
|
|
$sw.Stop()
|
|
if ($shareMounted) {
|
|
Log ("Share mounted as R: after {0:N1} s" -f $sw.Elapsed.TotalSeconds)
|
|
} else {
|
|
Log "Mount-SFLDShare failed after $ShareTimeoutSec s. SFLD creds may be missing in HKLM:\SOFTWARE\GE\SFLD\Credentials, or the share is unreachable. Exiting non-zero so the dispatcher logs a failure." 'ERROR'
|
|
Log 'Exit 1.'
|
|
exit 1
|
|
}
|
|
|
|
# All bay-level paths now go through R: (authenticated as SFLD user) so
|
|
# Test-Path returns the truth, not access-denied-False.
|
|
$bayDir = Join-Path 'R:\' $mn
|
|
$srcCur = Join-Path $bayDir 'CurrentData.json'
|
|
$srcArc = Join-Path $bayDir 'ArchivedData'
|
|
Log "Probing backup paths for bay $mn"
|
|
Log " bayDir: $bayDir"
|
|
$bayDirExists = Test-Path -LiteralPath $bayDir
|
|
Log " bayDir exists: $bayDirExists"
|
|
$srcCurExists = Test-Path -LiteralPath $srcCur
|
|
Log " CurrentData.json src: $(if ($srcCurExists) { 'present' } else { 'absent' }) - $srcCur"
|
|
$srcArcExists = Test-Path -LiteralPath $srcArc
|
|
Log " ArchivedData/ src: $(if ($srcArcExists) { 'present' } else { 'absent' }) - $srcArc"
|
|
|
|
if (-not $srcCurExists -and -not $srcArcExists) {
|
|
Log "No backup waiting for bay $mn (neither CurrentData.json nor ArchivedData\ at bay root) - no work to do this cycle."
|
|
& net use R: /delete /y 2>$null | Out-Null
|
|
Log 'Exit 0.'
|
|
exit 0
|
|
}
|
|
if (-not $srcCurExists) {
|
|
Log "Partial backup waiting (ArchivedData\ present, CurrentData.json absent). Will restore ArchivedData\ only. Source PC may have had no live UDC session at backup time, or backup partially failed." 'WARN'
|
|
}
|
|
if (-not $srcArcExists) {
|
|
Log "Partial backup waiting (CurrentData.json present, ArchivedData\ absent). Will restore CurrentData.json only." 'WARN'
|
|
}
|
|
|
|
# -- We have a backup. Restore. ------------------------------------------
|
|
Log "Backup waiting at $bayDir - proceeding with restore"
|
|
|
|
# Stop UDC.exe so CurrentData.json isn't locked
|
|
$udcProcs = @(Get-Process UDC -ErrorAction SilentlyContinue)
|
|
Log "UDC processes currently running: $($udcProcs.Count)"
|
|
foreach ($p in $udcProcs) {
|
|
try {
|
|
Log " stopping UDC.exe PID $($p.Id)"
|
|
$p.Kill()
|
|
$p.WaitForExit(5000) | Out-Null
|
|
Log " stopped"
|
|
} catch {
|
|
Log " could not stop UDC.exe PID $($p.Id)" 'WARN'
|
|
LogErr $_
|
|
}
|
|
}
|
|
Start-Sleep -Milliseconds 500
|
|
|
|
# Ensure local UDC data dir exists
|
|
if (-not (Test-Path -LiteralPath $UdcDataDir)) {
|
|
Log "Creating local UDC data dir: $UdcDataDir"
|
|
try {
|
|
New-Item -Path $UdcDataDir -ItemType Directory -Force | Out-Null
|
|
} catch {
|
|
Log "Failed to create $UdcDataDir - cannot continue" 'ERROR'
|
|
LogErr $_
|
|
& net use R: /delete /y 2>$null | Out-Null
|
|
Log 'Exit 1.'
|
|
exit 1
|
|
}
|
|
}
|
|
$localCur = Join-Path $UdcDataDir 'CurrentData.json'
|
|
$localArc = Join-Path $UdcDataDir 'ArchivedData'
|
|
|
|
# Copy CurrentData.json (only if present at source)
|
|
$copiedCur = $false
|
|
if ($srcCurExists) {
|
|
Log "Copying CurrentData.json"
|
|
Log " src: $srcCur"
|
|
Log " dst: $localCur"
|
|
try {
|
|
Copy-Item -LiteralPath $srcCur -Destination $localCur -Force -ErrorAction Stop
|
|
$copiedCur = $true
|
|
$sz = (Get-Item -LiteralPath $localCur).Length
|
|
Log " OK ($sz bytes)"
|
|
} catch {
|
|
Log " FAILED" 'ERROR'
|
|
LogErr $_
|
|
}
|
|
} else {
|
|
Log "CurrentData.json not present in backup - skipping that copy step"
|
|
}
|
|
|
|
# Copy ArchivedData/
|
|
$copiedArc = $false
|
|
$arcFiles = 0
|
|
$arcBytes = 0
|
|
if ($srcArcExists) {
|
|
Log "Copying ArchivedData/"
|
|
Log " src: $srcArc"
|
|
Log " dst: $localArc"
|
|
try {
|
|
if (Test-Path -LiteralPath $localArc) {
|
|
Log " removing existing $localArc"
|
|
Remove-Item -LiteralPath $localArc -Recurse -Force -ErrorAction SilentlyContinue
|
|
}
|
|
Copy-Item -LiteralPath $srcArc -Destination $localArc -Recurse -Force -ErrorAction Stop
|
|
$arcItems = Get-ChildItem -LiteralPath $localArc -Recurse -File -ErrorAction SilentlyContinue
|
|
$arcFiles = @($arcItems).Count
|
|
$arcBytes = ($arcItems | Measure-Object Length -Sum).Sum
|
|
$copiedArc = $true
|
|
Log " OK ($arcFiles files, $arcBytes bytes)"
|
|
} catch {
|
|
Log " FAILED" 'ERROR'
|
|
LogErr $_
|
|
}
|
|
} else {
|
|
Log "ArchivedData/ not present in backup - skipping that copy step"
|
|
}
|
|
|
|
# One-shot consumption: only consume when every present source has been
|
|
# successfully copied. If a source was absent we don't fault on it; if a
|
|
# source was present but copy failed, we leave the live backup for retry.
|
|
# Must have copied at least one thing to consume.
|
|
$consumeOk = (($copiedCur -or -not $srcCurExists) -and `
|
|
($copiedArc -or -not $srcArcExists) -and `
|
|
($copiedCur -or $copiedArc))
|
|
Log "consumeOk=$consumeOk (copiedCur=$copiedCur, copiedArc=$copiedArc, srcCurExists=$srcCurExists, srcArcExists=$srcArcExists)"
|
|
|
|
if ($consumeOk) {
|
|
try {
|
|
$stamp = Get-Date -Format 'yyyy-MM-ddTHH-mm-ssZ'
|
|
$migDir = Join-Path $bayDir 'migrated'
|
|
$migStamp = Join-Path $migDir $stamp
|
|
Log "Moving consumed backup to $migStamp"
|
|
if (-not (Test-Path -LiteralPath $migDir)) { New-Item -ItemType Directory -Path $migDir -Force | Out-Null }
|
|
if (-not (Test-Path -LiteralPath $migStamp)) { New-Item -ItemType Directory -Path $migStamp -Force | Out-Null }
|
|
|
|
if (Test-Path -LiteralPath $srcCur) {
|
|
Move-Item -LiteralPath $srcCur -Destination (Join-Path $migStamp 'CurrentData.json') -Force -ErrorAction Stop
|
|
Log " moved CurrentData.json"
|
|
}
|
|
if (Test-Path -LiteralPath $srcArc) {
|
|
Move-Item -LiteralPath $srcArc -Destination (Join-Path $migStamp 'ArchivedData') -Force -ErrorAction Stop
|
|
Log " moved ArchivedData/"
|
|
}
|
|
$bakManifest = Join-Path $bayDir 'backup.manifest.json'
|
|
if (Test-Path -LiteralPath $bakManifest) {
|
|
Move-Item -LiteralPath $bakManifest -Destination (Join-Path $migStamp 'backup.manifest.json') -Force -ErrorAction SilentlyContinue
|
|
Log " moved backup.manifest.json"
|
|
}
|
|
|
|
$restoreManifest = [ordered]@{
|
|
RestoredAt = (Get-Date -Format 'o')
|
|
DestinationHostname = $env:COMPUTERNAME
|
|
DestinationUser = $whoami
|
|
MachineNumber = $mn
|
|
CurrentDataPresent = $copiedCur
|
|
CurrentDataBytes = if ($copiedCur) { (Get-Item -LiteralPath $localCur).Length } else { 0 }
|
|
ArchivedDataPresent = $copiedArc
|
|
ArchivedDataFiles = $arcFiles
|
|
ArchivedDataBytes = $arcBytes
|
|
RestoredVia = 'Restore-UDCData.ps1 (manifest engine, on logon)'
|
|
}
|
|
$restoreManifest | ConvertTo-Json | Set-Content -Path (Join-Path $migStamp 'restore.manifest.json') -Encoding UTF8
|
|
Log " wrote restore.manifest.json"
|
|
|
|
Log "Backup consumed -> migrated\$stamp\"
|
|
} catch {
|
|
Log "Move-to-migrated FAILED (data IS restored locally; live backup remains, next cycle will retry consumption)" 'ERROR'
|
|
LogErr $_
|
|
}
|
|
} else {
|
|
Log "Restore incomplete - leaving live backup at $bayDir for retry next cycle." 'WARN'
|
|
}
|
|
|
|
# Relaunch UDC with the current machine number args. UDC's vendor autostart in
|
|
# HKLM\Run will also fire on the next user logon, so this is belt-and-suspenders
|
|
# for the same-session case (e.g. tech is at the keyboard during the restore).
|
|
if ((Test-Path -LiteralPath $UdcExePath) -and ($copiedCur -or $copiedArc)) {
|
|
Log "Relaunching UDC.exe: `"$Site`" -$mn"
|
|
try {
|
|
Start-Process -FilePath $UdcExePath -ArgumentList @("`"$Site`"", "-$mn")
|
|
Log " relaunched"
|
|
} catch {
|
|
Log " UDC relaunch FAILED" 'WARN'
|
|
LogErr $_
|
|
}
|
|
}
|
|
|
|
# Unmount the SFLD-creds-mounted drive so we don't leave a stale net-use entry
|
|
& net use R: /delete /y 2>$null | Out-Null
|
|
|
|
Log 'Exit 0.'
|
|
Log '==============================================='
|
|
exit 0
|