sweep: pre-existing drift + matrix UDC entry + ignore 142MB EXE
Bundles drift left uncommitted from prior sessions and the UDC matrix
verify entry added today.
Drift items (all per session-progress.md, completed in earlier sessions
but never staged):
- playbook/check-bios.cmd (deleted, moved to BIOS/check-bios.cmd)
- playbook/migrate-to-wifi.ps1 (made no-op 2026-04-24 after the dnsmasq
no-gateway fix removed the wired-NIC race that motivated it)
- playbook/preinstall/oracle/Install-Oracle11r2.cmd (post-OUI .ora copy
added 2026-04-24)
- playbook/preinstall/oracle/tnsnames.ora (live tnsnames, 469 KB,
deployed alongside the wrapper 2026-04-24)
- playbook/pxe_server_setup.yml (dnsmasq dhcp-option=3,6 commented,
Oracle .ora deploy task added 2026-04-24)
- playbook/shopfloor-setup/BIOS/{check-bios.cmd, models.txt} (BIOS
detection refinements)
- playbook/shopfloor-setup/Shopfloor/Force-Lockdown.bat
- playbook/shopfloor-setup/Shopfloor/Monitor-IntuneProgress.ps1
- playbook/shopfloor-setup/Shopfloor/SetShopfloorAutoLogon.bat (new)
- playbook/shopfloor-setup/Shopfloor/09-Install-PrinterInstallerMap.ps1
(new, places PrinterInstallerMap.exe + Public Desktop shortcut at
imaging time; manifest entry self-heals on tamper)
- playbook/shopfloor-setup/Shopfloor/lib/Show-IntuneDeviceQR.ps1 (new,
standalone QR rendering for site that wanted just that piece)
- playbook/shopfloor-setup/gea-shopfloor-collections/{Install-eMxInfo.cmd.template,
Restore-UDCData.ps1} (these were uncommitted in pre-rename Standard/;
git mv didn't catch them because they were untracked at the time)
- docs/shopfloor-machine-imaging-guide.md (operator-facing how-to)
Matrix:
- common.test/matrix.json: add UDC verify entry to gea-shopfloor-collections
row. Surfaces UDC silent-install issue (item H pending) instead of
letting it pass silently.
.gitignore:
- PrinterInstallerMap.exe (142 MB) excluded. Track via LFS or stage on
PXE server only - too big for regular git history. Untouched on disk
so existing local copy still works.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,40 +1,39 @@
|
||||
@echo off
|
||||
REM check-bios.cmd - Check and apply Dell BIOS update from WinPE x64
|
||||
REM Called from startnet.cmd before imaging menu
|
||||
REM Requires: Flash64W.exe (Dell 64-Bit BIOS Flash Utility) in same directory
|
||||
REM
|
||||
REM Exit behavior:
|
||||
REM - BIOS update applied -> reboots automatically (flashes during POST)
|
||||
REM - Already up to date -> returns to startnet.cmd
|
||||
REM - No match / no files -> returns to startnet.cmd
|
||||
REM Sets BIOS_STATUS for startnet.cmd menu display
|
||||
|
||||
set BIOSDIR=%~dp0
|
||||
set FLASH=%BIOSDIR%Flash64W.exe
|
||||
set MANIFEST=%BIOSDIR%models.txt
|
||||
set "BIOSDIR=%~dp0"
|
||||
set "FLASH=%BIOSDIR%Flash64W.exe"
|
||||
set "MANIFEST=%BIOSDIR%models.txt"
|
||||
|
||||
if not exist "%FLASH%" (
|
||||
echo Flash64W.exe not found, skipping BIOS check.
|
||||
exit /b 0
|
||||
)
|
||||
if exist "%FLASH%" goto :flash_ok
|
||||
echo Flash64W.exe not found, skipping BIOS check.
|
||||
set "BIOS_STATUS=Skipped (Flash64W.exe missing)"
|
||||
exit /b 0
|
||||
|
||||
if not exist "%MANIFEST%" (
|
||||
echo models.txt not found, skipping BIOS check.
|
||||
exit /b 0
|
||||
)
|
||||
:flash_ok
|
||||
if exist "%MANIFEST%" goto :manifest_ok
|
||||
echo models.txt not found, skipping BIOS check.
|
||||
set "BIOS_STATUS=Skipped (models.txt missing)"
|
||||
exit /b 0
|
||||
|
||||
:manifest_ok
|
||||
REM --- Get system model from WMI ---
|
||||
set SYSMODEL=
|
||||
for /f "skip=1 tokens=*" %%M in ('wmic csproduct get name 2^>NUL') do (
|
||||
if not defined SYSMODEL set "SYSMODEL=%%M"
|
||||
)
|
||||
REM Trim trailing whitespace
|
||||
for /f "tokens=*" %%a in ("%SYSMODEL%") do set "SYSMODEL=%%a"
|
||||
|
||||
if "%SYSMODEL%"=="" (
|
||||
echo Could not detect system model, skipping BIOS check.
|
||||
exit /b 0
|
||||
)
|
||||
if "%SYSMODEL%"=="" goto :no_model
|
||||
goto :got_model
|
||||
|
||||
:no_model
|
||||
echo Could not detect system model, skipping BIOS check.
|
||||
set "BIOS_STATUS=Skipped (model not detected)"
|
||||
exit /b 0
|
||||
|
||||
:got_model
|
||||
echo Model: %SYSMODEL%
|
||||
|
||||
REM --- Get current BIOS version ---
|
||||
@@ -46,7 +45,6 @@ for /f "tokens=*" %%a in ("%BIOSVER%") do set "BIOSVER=%%a"
|
||||
echo Current BIOS: %BIOSVER%
|
||||
|
||||
REM --- Read manifest and find matching BIOS file ---
|
||||
REM Format: ModelSubstring|BIOSFile|Version
|
||||
set BIOSFILE=
|
||||
set TARGETVER=
|
||||
for /f "usebackq eol=# tokens=1,2,3 delims=|" %%A in ("%MANIFEST%") do (
|
||||
@@ -59,60 +57,60 @@ for /f "usebackq eol=# tokens=1,2,3 delims=|" %%A in ("%MANIFEST%") do (
|
||||
)
|
||||
|
||||
echo No BIOS update available for this model.
|
||||
set "BIOS_STATUS=%SYSMODEL% - no update in catalog"
|
||||
exit /b 0
|
||||
|
||||
:found_bios
|
||||
if not exist "%BIOSDIR%%BIOSFILE%" (
|
||||
echo WARNING: %BIOSFILE% not found in BIOS folder.
|
||||
exit /b 0
|
||||
)
|
||||
if not exist "%BIOSDIR%%BIOSFILE%" goto :bios_file_missing
|
||||
goto :bios_file_ok
|
||||
|
||||
:bios_file_missing
|
||||
echo WARNING: %BIOSFILE% not found in BIOS folder.
|
||||
set "BIOS_STATUS=%SYSMODEL% - %BIOSFILE% missing"
|
||||
exit /b 0
|
||||
|
||||
:bios_file_ok
|
||||
REM --- Skip if already at target version ---
|
||||
echo.%BIOSVER%| find /I "%TARGETVER%" >NUL
|
||||
if not errorlevel 1 goto :already_current
|
||||
|
||||
REM --- Compare versions to prevent downgrade ---
|
||||
REM Split current and target into major.minor.patch and compare numerically
|
||||
call :compare_versions "%BIOSVER%" "%TARGETVER%"
|
||||
if "%VERCMP%"=="newer" goto :already_newer
|
||||
goto :do_flash
|
||||
|
||||
:already_current
|
||||
echo BIOS is already up to date - %TARGETVER%
|
||||
set "BIOS_STATUS=%SYSMODEL% v%BIOSVER% (up to date)"
|
||||
exit /b 0
|
||||
|
||||
:already_newer
|
||||
echo Current BIOS %BIOSVER% is newer than target %TARGETVER% - skipping.
|
||||
set "BIOS_STATUS=%SYSMODEL% v%BIOSVER% (up to date)"
|
||||
exit /b 0
|
||||
|
||||
:do_flash
|
||||
|
||||
echo Update: %BIOSVER% -^> %TARGETVER%
|
||||
echo Applying BIOS update (this may take a few minutes, do not power off)...
|
||||
|
||||
REM --- Run Flash64W.exe from BIOS directory to avoid UNC path issues ---
|
||||
REM Exit codes: 0=success, 2=reboot needed, 3=already current, 6=reboot needed
|
||||
pushd "%BIOSDIR%"
|
||||
Flash64W.exe /b="%BIOSFILE%" /s /f /l=X:\bios-update.log
|
||||
set FLASHRC=%ERRORLEVEL%
|
||||
popd
|
||||
echo Flash complete (exit code %FLASHRC%).
|
||||
|
||||
if "%FLASHRC%"=="3" (
|
||||
echo BIOS is already up to date.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if "%FLASHRC%"=="0" (
|
||||
echo BIOS update complete.
|
||||
exit /b 0
|
||||
)
|
||||
|
||||
if "%FLASHRC%"=="3" goto :already_current
|
||||
if "%FLASHRC%"=="0" goto :flash_done
|
||||
if "%FLASHRC%"=="2" goto :staged
|
||||
if "%FLASHRC%"=="6" goto :staged
|
||||
|
||||
echo WARNING: Flash64W.exe returned unexpected code %FLASHRC%.
|
||||
echo Check X:\bios-update.log for details.
|
||||
set "BIOS_STATUS=%SYSMODEL% flash error (code %FLASHRC%)"
|
||||
exit /b 0
|
||||
|
||||
:flash_done
|
||||
echo BIOS update complete.
|
||||
set "BIOS_STATUS=%SYSMODEL% updated %BIOSVER% -^> %TARGETVER%"
|
||||
exit /b 0
|
||||
|
||||
:staged
|
||||
@@ -123,31 +121,23 @@ echo It will flash during POST after the
|
||||
echo post-imaging reboot.
|
||||
echo ========================================
|
||||
echo.
|
||||
set "BIOS_STATUS=%SYSMODEL% STAGED %BIOSVER% -^> %TARGETVER% (flashes on reboot)"
|
||||
exit /b 0
|
||||
|
||||
REM ============================================================
|
||||
REM compare_versions - Compare two dotted version strings
|
||||
REM Usage: call :compare_versions "current" "target"
|
||||
REM Sets VERCMP=newer if current > target, older if current < target, equal if same
|
||||
REM ============================================================
|
||||
:compare_versions
|
||||
set "VERCMP=equal"
|
||||
set "_CV=%~1"
|
||||
set "_TV=%~2"
|
||||
|
||||
REM Parse current version parts
|
||||
for /f "tokens=1,2,3 delims=." %%a in ("%_CV%") do (
|
||||
set /a "C1=%%a" 2>NUL
|
||||
set /a "C2=%%b" 2>NUL
|
||||
set /a "C3=%%c" 2>NUL
|
||||
)
|
||||
REM Parse target version parts
|
||||
for /f "tokens=1,2,3 delims=." %%a in ("%_TV%") do (
|
||||
set /a "T1=%%a" 2>NUL
|
||||
set /a "T2=%%b" 2>NUL
|
||||
set /a "T3=%%c" 2>NUL
|
||||
)
|
||||
|
||||
if %C1% GTR %T1% ( set "VERCMP=newer" & goto :eof )
|
||||
if %C1% LSS %T1% ( set "VERCMP=older" & goto :eof )
|
||||
if %C2% GTR %T2% ( set "VERCMP=newer" & goto :eof )
|
||||
|
||||
@@ -45,3 +45,4 @@ Precision 7865 Tower|Precision_7865_1.6.1.exe|1.6.1
|
||||
Precision 7875 Tower|Precision_7875_SHP_02.07.03.exe|2.7.3
|
||||
Rugged 14 RB14250|Dell_Pro_Rugged_RB14250_RA13250_1.13.1.exe|1.13.1
|
||||
Tower Plus 7020|OptiPlex_7020_1.22.1_SEMB.exe|1.22.1
|
||||
Tower Plus QBT1250|Dell_Pro_QBT1250_QBS1250_QBM1250_QCT1250_QCS1250_QCM1250_SEMB_1.12.2.exe|1.12.2
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
# 09-Install-PrinterInstallerMap.ps1 - imaging-time placement of the
|
||||
# PrinterInstallerMap.exe tool plus a Public Desktop shortcut.
|
||||
# Runs for every shopfloor PC type (Standard, CMM, Keyence, Display,
|
||||
# etc.) so a tech can launch the printer installer at any bay without
|
||||
# going through Edge. Numbered 09 to run after 06-OrganizeDesktop
|
||||
# (which creates Shopfloor Tools folder) and 08-EdgeDefaultBrowser.
|
||||
#
|
||||
# Why this exists: the legacy printer-deploy flow was a web map
|
||||
# (printerinstallermap.asp) that handed users a per-selection .bat to
|
||||
# download and run. New Edge policy blocks both .bat downloads and
|
||||
# unsigned .exe runs. PrinterInstallerMap is a forked installer with the
|
||||
# map UX baked into a custom Inno wizard page; baking the EXE onto the
|
||||
# image at PXE time means it never goes through a browser (no MOTW, no
|
||||
# SmartScreen prompt). UAC still fires because PrivilegesRequired=admin
|
||||
# and supportuser creds satisfy it - that is the only remaining gate.
|
||||
#
|
||||
# Layout this script produces on a Standard PC:
|
||||
# C:\Tools\PrinterInstallerMap\PrinterInstallerMap.exe
|
||||
# C:\Users\Public\Desktop\Shopfloor Tools\Printer Installer.lnk
|
||||
#
|
||||
# The Shopfloor Tools desktop folder is also created/maintained by
|
||||
# 06-OrganizeDesktop.ps1; this script tolerates either order (it creates
|
||||
# the folder if absent so it can run before 06).
|
||||
#
|
||||
# Idempotent: re-imaging or re-running the script overwrites the EXE and
|
||||
# rewrites the shortcut. Drop-in newer PrinterInstallerMap.exe alongside
|
||||
# this script in the imaging payload, re-image the PC, the desktop link
|
||||
# now points to the new build.
|
||||
#
|
||||
# Source EXE expected at $PSScriptRoot\PrinterInstallerMap.exe (placed
|
||||
# next to this script when the imaging payload lands). Until the Inno
|
||||
# compile + sign+ship step is wired in (see ../../docs/ + the inno repo
|
||||
# feature/printer-map branch), this script no-ops with a warning if the
|
||||
# EXE is missing - re-image will pick it up once the build artifact is
|
||||
# dropped in.
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
$src = Join-Path $PSScriptRoot 'PrinterInstallerMap.exe'
|
||||
$installDir = 'C:\Tools\PrinterInstallerMap'
|
||||
$installExe = Join-Path $installDir 'PrinterInstallerMap.exe'
|
||||
$publicDesktop = 'C:\Users\Public\Desktop'
|
||||
$shopfloorTools = Join-Path $publicDesktop 'Shopfloor Tools'
|
||||
$shortcutPath = Join-Path $shopfloorTools 'Printer Installer.lnk'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $src)) {
|
||||
Write-Warning "Install-PrinterInstallerMap: source EXE not found at $src - skipping. Re-image after the build artifact is dropped alongside this script."
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Stage EXE
|
||||
if (-not (Test-Path -LiteralPath $installDir)) {
|
||||
New-Item -Path $installDir -ItemType Directory -Force | Out-Null
|
||||
}
|
||||
try {
|
||||
Copy-Item -LiteralPath $src -Destination $installExe -Force -ErrorAction Stop
|
||||
Write-Host "Install-PrinterInstallerMap: staged EXE -> $installExe"
|
||||
} catch {
|
||||
Write-Error "Install-PrinterInstallerMap: failed to copy EXE: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Ensure Shopfloor Tools folder exists. 06-OrganizeDesktop.ps1 also
|
||||
# creates this; we create it here so this script does not depend on
|
||||
# 06 having run first.
|
||||
if (-not (Test-Path -LiteralPath $shopfloorTools)) {
|
||||
try {
|
||||
New-Item -Path $shopfloorTools -ItemType Directory -Force | Out-Null
|
||||
} catch {
|
||||
Write-Warning "Install-PrinterInstallerMap: could not create $shopfloorTools : $_"
|
||||
}
|
||||
}
|
||||
|
||||
# Create / overwrite shortcut
|
||||
try {
|
||||
$wsh = New-Object -ComObject WScript.Shell
|
||||
$lnk = $wsh.CreateShortcut($shortcutPath)
|
||||
$lnk.TargetPath = $installExe
|
||||
$lnk.IconLocation = "$installExe,0"
|
||||
$lnk.WorkingDirectory = $installDir
|
||||
$lnk.Description = 'Install network printers from the WJ site map'
|
||||
$lnk.Save()
|
||||
Write-Host "Install-PrinterInstallerMap: shortcut -> $shortcutPath"
|
||||
} catch {
|
||||
Write-Error "Install-PrinterInstallerMap: failed to create shortcut: $_"
|
||||
exit 1
|
||||
}
|
||||
|
||||
exit 0
|
||||
@@ -60,6 +60,18 @@ echo.
|
||||
PowerShell.exe -NoProfile -ExecutionPolicy Bypass -File "%SCRIPT%"
|
||||
set RC=%errorLevel%
|
||||
|
||||
REM On success, write a marker that Monitor-IntuneProgress.ps1 (Phase 6 /
|
||||
REM Get-LockdownState) recognizes as authoritative. Manual lockdown via
|
||||
REM sfld_autologon.ps1 only flips Winlogon; the Intune Remediation log
|
||||
REM never gets the "Autologon set for ShopFloor" line because Detection
|
||||
REM now passes and Remediation never re-runs. The marker tells the monitor
|
||||
REM to treat (Winlogon registry matches + marker present) as Complete.
|
||||
if "%RC%"=="0" (
|
||||
if not exist "C:\Enrollment" mkdir "C:\Enrollment"
|
||||
> "C:\Enrollment\force-lockdown-applied.txt" echo %DATE% %TIME%
|
||||
echo Marker written: C:\Enrollment\force-lockdown-applied.txt
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================================
|
||||
echo Lockdown script exit code: %RC%
|
||||
|
||||
12
playbook/shopfloor-setup/Shopfloor/SetShopfloorAutoLogon.bat
Executable file
12
playbook/shopfloor-setup/Shopfloor/SetShopfloorAutoLogon.bat
Executable file
@@ -0,0 +1,12 @@
|
||||
REM 1) Check if we are running elevated (admin)
|
||||
net session >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo Requesting elevation...
|
||||
powershell -NoProfile -ExecutionPolicy Bypass ^
|
||||
-Command "Start-Process -FilePath '%~f0' -Verb RunAs"
|
||||
goto :EOF
|
||||
)
|
||||
|
||||
|
||||
|
||||
"C:\Program Files\PowerShell\7\pwsh.exe" -NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\Sysinternals\sfld_autologon.ps1"
|
||||
@@ -303,34 +303,91 @@ function Get-LockdownState {
|
||||
# Autologon_Remediation.log - "Autologon set for ShopFloor user ..."
|
||||
# Autologon_Detection.log - "... matches the expected value: 1"
|
||||
#
|
||||
# These are the TRUE end-of-chain signals. The DefaultUserName flip and
|
||||
# admin rename that we previously checked land at PPKG time (too early);
|
||||
# the IME logs only appear after Intune enrollment + category + DSC +
|
||||
# Remediation cycle, which is the actual lockdown completion.
|
||||
# IME logs are append-only - they never rotate or clear. Earlier logic
|
||||
# used `-match` against the whole raw file, which stayed true forever
|
||||
# once success was ever recorded, even if the policy later changed and
|
||||
# the current state drifted. The fix below:
|
||||
# (a) Walks lines from END to find the MOST RECENT outcome per log
|
||||
# (latest attempt wins, stale successes are ignored)
|
||||
# (b) Cross-checks Winlogon registry for ground truth - catches the
|
||||
# case where IME logs say success but the reg state got rolled
|
||||
# back. All three must agree for Complete to be true.
|
||||
$imeLogs = 'C:\ProgramData\Microsoft\IntuneManagementExtension\Logs'
|
||||
|
||||
# --- Remediation: scan from tail for the LATEST result line
|
||||
$remLog = Join-Path $imeLogs 'Autologon_Remediation.log'
|
||||
$remDone = $false
|
||||
if (Test-Path $remLog) {
|
||||
try {
|
||||
$content = Get-Content $remLog -Raw -ErrorAction Stop
|
||||
$remDone = ($content -match 'Autologon set for ShopFloor')
|
||||
$lines = Get-Content $remLog -ErrorAction Stop
|
||||
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
|
||||
$ln = $lines[$i]
|
||||
if ($ln -match 'Autologon set for ShopFloor') {
|
||||
$remDone = $true
|
||||
break
|
||||
}
|
||||
if ($ln -match 'Autologon could not be set|Remediation failed|FATAL|Exception') {
|
||||
$remDone = $false
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# --- Detection: scan from tail for the LATEST "matches the expected value: <N>"
|
||||
$detLog = Join-Path $imeLogs 'Autologon_Detection.log'
|
||||
$detDone = $false
|
||||
if (Test-Path $detLog) {
|
||||
try {
|
||||
$content = Get-Content $detLog -Raw -ErrorAction Stop
|
||||
$detDone = ($content -match 'matches the expected value:\s*1')
|
||||
$lines = Get-Content $detLog -ErrorAction Stop
|
||||
for ($i = $lines.Count - 1; $i -ge 0; $i--) {
|
||||
if ($lines[$i] -match 'matches the expected value:\s*(\d+)') {
|
||||
$detDone = ($matches[1] -eq '1')
|
||||
break
|
||||
}
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
# --- Ground truth: does Winlogon actually reflect ShopFloor autologon?
|
||||
# This catches the case where IME logs report historical success but the
|
||||
# reg state has since been changed back (manual tech intervention, policy
|
||||
# rollback, GPO override, etc).
|
||||
$registryMatches = $false
|
||||
try {
|
||||
$wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -ErrorAction Stop
|
||||
$registryMatches = (
|
||||
[string]$wl.AutoAdminLogon -eq '1' -and
|
||||
$wl.DefaultUserName -and
|
||||
$wl.DefaultUserName -like 'ShopFloor*'
|
||||
)
|
||||
} catch {}
|
||||
|
||||
# --- Manual override marker written by Force-Lockdown.bat.
|
||||
# When a tech runs sfld_autologon.ps1 directly (vendor-documented escape
|
||||
# hatch when the Intune push hasn't applied within ~30 minutes), only
|
||||
# Winlogon flips - the IME Remediation log never gets a new success
|
||||
# entry because Detection now passes and Remediation isn't invoked.
|
||||
# Without this marker, the strict (rem AND det AND reg) gate stays
|
||||
# false forever even though the device is fully locked down.
|
||||
$forceMarker = 'C:\Enrollment\force-lockdown-applied.txt'
|
||||
$forceApplied = (Test-Path -LiteralPath $forceMarker)
|
||||
|
||||
return @{
|
||||
RemediationApplied = $remDone
|
||||
DetectionPassed = $detDone
|
||||
Complete = ($remDone -and $detDone)
|
||||
RegistryMatches = $registryMatches
|
||||
ForceApplied = $forceApplied
|
||||
# Two paths to Complete:
|
||||
# 1. Normal Intune-driven: all three signals present (closes the
|
||||
# append-only-log false-positive AND rollback-without-log case).
|
||||
# 2. Manual escape hatch: marker file + ground-truth registry
|
||||
# match. Marker is the audit-trail substitute for the missing
|
||||
# Remediation log entry; registry is still the gate.
|
||||
Complete = (
|
||||
($remDone -and $detDone -and $registryMatches) -or
|
||||
($forceApplied -and $registryMatches)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -580,11 +637,21 @@ function Format-Snapshot {
|
||||
@{ Ok = $Snap.Phase1.PoliciesArriving; Failed = $false }
|
||||
)
|
||||
|
||||
# Phase 6 / Lockdown (shared by both flows, rendered last)
|
||||
$p6Status = Get-PhaseStatus @(
|
||||
@{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
|
||||
@{ Ok = $Snap.Phase6.DetectionPassed; Failed = $false }
|
||||
)
|
||||
# Phase 6 / Lockdown (shared by both flows, rendered last).
|
||||
# RegistryMatches is the ground-truth check - if it flips to false after a
|
||||
# historical success, something rolled back Winlogon and lockdown has drifted.
|
||||
# If the manual Force-Lockdown marker is present, treat the row as COMPLETE
|
||||
# to match the Get-LockdownState gate (avoids the table reading IN PROGRESS
|
||||
# while the script is about to advance to setup-complete on this same tick).
|
||||
if ($Snap.Phase6.Complete) {
|
||||
$p6Status = 'COMPLETE'
|
||||
} else {
|
||||
$p6Status = Get-PhaseStatus @(
|
||||
@{ Ok = $Snap.Phase6.RemediationApplied; Failed = $false },
|
||||
@{ Ok = $Snap.Phase6.DetectionPassed; Failed = $false },
|
||||
@{ Ok = $Snap.Phase6.RegistryMatches; Failed = $false }
|
||||
)
|
||||
}
|
||||
|
||||
if (-not $skipDsc) {
|
||||
# ---- Standard / CMM / etc. (DSC flow) ----
|
||||
@@ -600,24 +667,16 @@ function Format-Snapshot {
|
||||
@{ Ok = $Snap.Phase3.DeployComplete; Failed = $false },
|
||||
@{ Ok = $Snap.Phase3.InstallComplete; Failed = $false }
|
||||
)
|
||||
$p4HasFailed = $false; $p4AllDone = $true; $p4AnyStarted = $false
|
||||
$p4HasScripts = ($Snap.Phase4 -and $Snap.Phase4.Count -gt 0)
|
||||
if ($p4HasScripts) {
|
||||
foreach ($s in $Snap.Phase4) {
|
||||
if ($s.Status -eq 'failed') { $p4HasFailed = $true }
|
||||
if ($s.Status -ne 'done') { $p4AllDone = $false }
|
||||
if ($s.Status -ne 'pending') { $p4AnyStarted = $true }
|
||||
}
|
||||
} else {
|
||||
# No scripts discovered. If DSC install is already complete,
|
||||
# there are simply no custom scripts for this image type --
|
||||
# that's COMPLETE, not WAITING.
|
||||
$p4AllDone = $Snap.Phase3.InstallComplete
|
||||
}
|
||||
$p4Status = if ($p4HasFailed) { 'FAILED' } elseif ($p4AllDone) { 'COMPLETE' } elseif ($p4AnyStarted) { 'IN PROGRESS' } else { 'WAITING' }
|
||||
# Phase 4 (per-script Application Install) was retired 2026-04-28:
|
||||
# apps now deploy via the GE-Enforce manifest engine off
|
||||
# \\tsgwp00525\...\machineapps\, gated on AESFMA WiFi + S: mount
|
||||
# which both happen post-reboot. The monitor's responsibility ends
|
||||
# at "DSC bootstrap + creds + lockdown landed, time to reboot."
|
||||
# The manifest engine takes over from there on the next ShopFloor
|
||||
# logon.
|
||||
|
||||
$p5Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
|
||||
$p5Status = Get-PhaseStatus @(
|
||||
$p4Done = ($Snap.Phase5.ConsumeCredsTask -and $Snap.Phase5.CredsPopulated)
|
||||
$p4Status = Get-PhaseStatus @(
|
||||
@{ Ok = $Snap.Phase5.ConsumeCredsTask; Failed = $false },
|
||||
@{ Ok = $Snap.Phase5.CredsPopulated; Failed = $false }
|
||||
)
|
||||
@@ -629,12 +688,11 @@ function Format-Snapshot {
|
||||
}
|
||||
Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2Status; Write-Host ''
|
||||
Write-Host ' 3. Software Deployment ' -NoNewline; Format-StatusTag $p3Status; Write-Host ''
|
||||
Write-Host ' 4. Application Install ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
|
||||
Write-Host ' 5. Credential Setup ' -NoNewline; Format-StatusTag $p5Status; Write-Host ''
|
||||
if ($p5Done -and $p6Status -ne 'COMPLETE') {
|
||||
Write-Host ' 4. Credential Setup ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
|
||||
if ($p4Done -and $p6Status -ne 'COMPLETE') {
|
||||
Write-Host ' >> Initiate ARTS Lockdown request' -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ' 6. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
|
||||
Write-Host ' 5. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
|
||||
} else {
|
||||
# ---- Display (no DSC, no credentials) ----
|
||||
# Phases: 1 Registration -> 2 Device Configuration -> 3 Lockdown
|
||||
@@ -863,17 +921,21 @@ try {
|
||||
Write-Host ""
|
||||
Write-Host $qrText
|
||||
|
||||
# Final state: every phase landed. The gate is intentionally strict
|
||||
# because each piece is needed for the device to function:
|
||||
# - DscInstallComplete: device-config.yaml apps + custom scripts ran
|
||||
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
|
||||
# Acrobat-Enforce, CMM-Enforce all need these)
|
||||
# - LockdownComplete: kiosk policy baseline + Winlogon flipped to
|
||||
# ShopFloor autologon + admin renamed
|
||||
# Final state: every phase landed. Apps are no longer in the gate -
|
||||
# they deploy via the GE-Enforce manifest engine post-reboot, gated
|
||||
# on AESFMA WiFi + S: drive mount which only happen after we hand
|
||||
# off to the ShopFloor autologon session. So this gate is now:
|
||||
# - CredsPopulated: SFLD share creds in HKLM (Machine-Enforce,
|
||||
# Acrobat-Enforce, CMM-Enforce, manifest
|
||||
# engine all need these)
|
||||
# - LockdownComplete: kiosk policy baseline + Winlogon flipped to
|
||||
# ShopFloor autologon + admin renamed
|
||||
# DscInstallComplete used to be required, but device-config.yaml
|
||||
# is becoming a no-op as apps migrate to the manifest engine; a
|
||||
# 403 on the YAML download (e.g. expired SAS token) shouldn't
|
||||
# block the imaging workflow if creds + lockdown are otherwise OK.
|
||||
# Display PCs skip this whole branch via $skipDsc above.
|
||||
if ($snap.DscInstallComplete -and
|
||||
$snap.Phase5.CredsPopulated -and
|
||||
$snap.LockdownComplete) {
|
||||
if ($snap.Phase5.CredsPopulated -and $snap.LockdownComplete) {
|
||||
Invoke-SetupComplete
|
||||
}
|
||||
|
||||
@@ -912,8 +974,7 @@ try {
|
||||
# Intune Remediation script mid-execution and delay lockdown.
|
||||
$waitingForLockdownOnly = $false
|
||||
if (-not $skipDsc) {
|
||||
$waitingForLockdownOnly = ($snap.DscInstallComplete -and
|
||||
$snap.Phase5.CredsPopulated -and
|
||||
$waitingForLockdownOnly = ($snap.Phase5.CredsPopulated -and
|
||||
-not $snap.LockdownComplete)
|
||||
} else {
|
||||
$waitingForLockdownOnly = ($snap.Phase1.AzureAdJoined -and
|
||||
|
||||
207
playbook/shopfloor-setup/Shopfloor/lib/Show-IntuneDeviceQR.ps1
Normal file
207
playbook/shopfloor-setup/Shopfloor/lib/Show-IntuneDeviceQR.ps1
Normal file
@@ -0,0 +1,207 @@
|
||||
# Show-IntuneDeviceQR.ps1 - Standalone version of the QR-code panel from
|
||||
# Monitor-IntuneProgress.ps1, extracted for use at sites that only need the
|
||||
# device-ID display (no DSC monitoring, no Intune sync triggers, no reboot
|
||||
# orchestration).
|
||||
#
|
||||
# Polls dsregcmd /status every 15s until the device reports an Azure AD
|
||||
# DeviceId, then renders that ID as a half-block QR code in the console
|
||||
# along with the literal text. Stays open until the user presses a key.
|
||||
#
|
||||
# REQUIREMENTS
|
||||
# - Windows PowerShell 5.1 (or PS 7) with .NET Framework available
|
||||
# - QRCoder.dll alongside this script (or at one of the fallback paths
|
||||
# below). Single ~75 KB native dependency, no internet needed.
|
||||
#
|
||||
# USAGE
|
||||
# powershell.exe -NoProfile -ExecutionPolicy Bypass -File Show-IntuneDeviceQR.ps1
|
||||
#
|
||||
# Or double-click via a one-line .bat:
|
||||
# powershell.exe -NoProfile -ExecutionPolicy Bypass -NoExit -File "%~dp0Show-IntuneDeviceQR.ps1"
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[int]$PollSeconds = 15,
|
||||
[string]$QRCoderDllPath = ''
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Continue'
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Locate QRCoder.dll. Search order:
|
||||
# 1. -QRCoderDllPath if explicitly passed
|
||||
# 2. Next to this script ($PSScriptRoot\QRCoder.dll)
|
||||
# 3. Current working directory
|
||||
# 4. WJ canonical staging path (kept for cross-site portability)
|
||||
# ----------------------------------------------------------------------------
|
||||
function Resolve-QRCoderDll {
|
||||
param([string]$Override)
|
||||
|
||||
$candidates = @()
|
||||
if ($Override) { $candidates += $Override }
|
||||
if ($PSScriptRoot) { $candidates += (Join-Path $PSScriptRoot 'QRCoder.dll') }
|
||||
$candidates += (Join-Path (Get-Location) 'QRCoder.dll')
|
||||
$candidates += 'C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll'
|
||||
|
||||
foreach ($c in $candidates) {
|
||||
if ($c -and (Test-Path -LiteralPath $c)) { return (Resolve-Path -LiteralPath $c).Path }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Get the AAD DeviceId from dsregcmd. Returns $null until the device is
|
||||
# Azure AD joined AND dsregcmd has populated the field.
|
||||
# ----------------------------------------------------------------------------
|
||||
function Get-AadDeviceId {
|
||||
try {
|
||||
$dsreg = dsregcmd /status 2>&1
|
||||
} catch {
|
||||
return $null
|
||||
}
|
||||
$line = $dsreg | Select-String 'DeviceId' | Select-Object -First 1
|
||||
if (-not $line) { return $null }
|
||||
$parts = $line.ToString().Split(':')
|
||||
if ($parts.Count -lt 2) { return $null }
|
||||
$id = $parts[1].Trim()
|
||||
if ([string]::IsNullOrWhiteSpace($id)) { return $null }
|
||||
return $id
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Half-block QR renderer. Each output character is 1 module wide, 2 modules
|
||||
# tall, using:
|
||||
# U+2588 = both top and bottom modules set
|
||||
# U+2580 = top only
|
||||
# U+2584 = bottom only
|
||||
# space = neither
|
||||
# Cuts QR height in half vs full-block rendering. Adds a 4-module quiet
|
||||
# zone manually since QRCoder's ModuleMatrix excludes it by default.
|
||||
# ----------------------------------------------------------------------------
|
||||
function Format-QrHalfBlocks {
|
||||
param([string]$Payload, [string]$DllPath, [int]$IndentSpaces = 8)
|
||||
|
||||
Add-Type -Path $DllPath
|
||||
$gen = New-Object QRCoder.QRCodeGenerator
|
||||
$data = $gen.CreateQrCode($Payload, [QRCoder.QRCodeGenerator+ECCLevel]::L)
|
||||
|
||||
$matrix = $data.ModuleMatrix
|
||||
$size = $matrix.Count
|
||||
$pad = 4
|
||||
$total = $size + 2 * $pad
|
||||
|
||||
$upper = [char]0x2580
|
||||
$lower = [char]0x2584
|
||||
$full = [char]0x2588
|
||||
$left = ' ' * $IndentSpaces
|
||||
|
||||
$lines = New-Object System.Collections.Generic.List[string]
|
||||
for ($y = 0; $y -lt $total; $y += 2) {
|
||||
$sb = New-Object System.Text.StringBuilder
|
||||
[void]$sb.Append($left)
|
||||
for ($x = 0; $x -lt $total; $x++) {
|
||||
$mx = $x - $pad
|
||||
$my1 = $y - $pad
|
||||
$my2 = $y + 1 - $pad
|
||||
|
||||
$top = ($my1 -ge 0 -and $my1 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my1].Get($mx))
|
||||
$bot = ($my2 -ge 0 -and $my2 -lt $size -and $mx -ge 0 -and $mx -lt $size -and $matrix[$my2].Get($mx))
|
||||
|
||||
if ($top -and $bot) { [void]$sb.Append($full) }
|
||||
elseif ($top) { [void]$sb.Append($upper) }
|
||||
elseif ($bot) { [void]$sb.Append($lower) }
|
||||
else { [void]$sb.Append(' ') }
|
||||
}
|
||||
$lines.Add($sb.ToString())
|
||||
}
|
||||
return $lines
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Bigger console so the QR + text frame fits in one viewport.
|
||||
# ----------------------------------------------------------------------------
|
||||
try {
|
||||
$rui = $Host.UI.RawUI
|
||||
$maxH = $rui.MaxPhysicalWindowSize.Height
|
||||
$targetWindow = [Math]::Min(58, [int]$maxH)
|
||||
$targetBuffer = [Math]::Max($targetWindow, 200)
|
||||
|
||||
$bs = $rui.BufferSize
|
||||
if ($bs.Height -lt $targetBuffer) { $bs.Height = $targetBuffer; $rui.BufferSize = $bs }
|
||||
|
||||
$ws = $rui.WindowSize
|
||||
if ($ws.Height -lt $targetWindow) { $ws.Height = $targetWindow; $rui.WindowSize = $ws }
|
||||
} catch {}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Wait for QRCoder.dll
|
||||
# ----------------------------------------------------------------------------
|
||||
$dllPath = Resolve-QRCoderDll -Override $QRCoderDllPath
|
||||
|
||||
# Mark-of-the-Web: when files arrive via SMB / zip download / email,
|
||||
# Windows attaches a Zone.Identifier alternate data stream that flags the
|
||||
# file as "from another computer". Add-Type then refuses to load the DLL
|
||||
# with "Operation is not supported" / "Could not load file or assembly".
|
||||
# Unblock-File silently strips the ADS. Best-effort: errors are non-fatal
|
||||
# (file may already be unblocked, or the user may not have write access).
|
||||
if ($dllPath) {
|
||||
try { Unblock-File -LiteralPath $dllPath -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
if ($PSCommandPath) {
|
||||
try { Unblock-File -LiteralPath $PSCommandPath -ErrorAction SilentlyContinue } catch {}
|
||||
}
|
||||
|
||||
if (-not $dllPath) {
|
||||
Write-Host ""
|
||||
Write-Host "ERROR: QRCoder.dll not found." -ForegroundColor Red
|
||||
Write-Host "Searched (in order):"
|
||||
if ($QRCoderDllPath) { Write-Host " - $QRCoderDllPath (-QRCoderDllPath)" }
|
||||
if ($PSScriptRoot) { Write-Host " - $(Join-Path $PSScriptRoot 'QRCoder.dll')" }
|
||||
Write-Host " - $(Join-Path (Get-Location) 'QRCoder.dll')"
|
||||
Write-Host " - C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll"
|
||||
Write-Host ""
|
||||
Write-Host "Drop QRCoder.dll alongside this script and re-run." -ForegroundColor Yellow
|
||||
exit 2
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Poll loop - retry every $PollSeconds until DeviceId appears
|
||||
# ----------------------------------------------------------------------------
|
||||
Write-Host ""
|
||||
Write-Host " Waiting for Azure AD device registration..." -ForegroundColor Cyan
|
||||
Write-Host " Polling dsregcmd every $PollSeconds s. Press Ctrl+C to abort." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
|
||||
$attempt = 0
|
||||
while ($true) {
|
||||
$attempt++
|
||||
$deviceId = Get-AadDeviceId
|
||||
if ($deviceId) { break }
|
||||
|
||||
Write-Host (" [{0}] attempt {1,3} no DeviceId yet, retrying in {2}s..." -f (Get-Date -Format 'HH:mm:ss'), $attempt, $PollSeconds) -ForegroundColor DarkGray
|
||||
Start-Sleep -Seconds $PollSeconds
|
||||
}
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Render
|
||||
# ----------------------------------------------------------------------------
|
||||
Clear-Host
|
||||
Write-Host ""
|
||||
Write-Host " GE Aerospace -- Intune Device ID" -ForegroundColor Cyan
|
||||
Write-Host " $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')" -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
Write-Host " Device ID: $deviceId" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
|
||||
try {
|
||||
$qrLines = Format-QrHalfBlocks -Payload $deviceId -DllPath $dllPath
|
||||
foreach ($l in $qrLines) { Write-Host $l }
|
||||
} catch {
|
||||
Write-Host " (QR generation failed: $($_.Exception.Message))" -ForegroundColor Red
|
||||
}
|
||||
|
||||
Write-Host ""
|
||||
Write-Host " Scan with phone camera or Intune admin app." -ForegroundColor DarkGray
|
||||
Write-Host ""
|
||||
Write-Host " Press any key to close..." -ForegroundColor Yellow
|
||||
try { [void][Console]::ReadKey($true) } catch { [void](Read-Host) }
|
||||
exit 0
|
||||
@@ -29,6 +29,7 @@
|
||||
{ "$ref": "common.fmsResolver" },
|
||||
{ "$ref": "common.oracle" },
|
||||
{ "$ref": "common.openText" },
|
||||
{ "name": "UDC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\UDC", "name": "DisplayVersion", "value": "1.0.34" } },
|
||||
{ "name": "FMS Primary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostPrimary", "value": "wjfms3.ae.ge.com" } },
|
||||
{ "name": "FMS Secondary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostSecondary", "value": "10.233.112.158" } },
|
||||
{ "name": "eDNC bundles NTLARS", "verify": { "method": "FileVersion", "path": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", "value": "6.4.5.0" } }
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
@echo off
|
||||
REM Install-eMxInfo.cmd - copy the site-specific eMxInfo.txt into both
|
||||
REM DNC Program Files install paths. Run by Install-FromManifest.ps1
|
||||
REM (Type=CMD) when Hash detection on the x86 path fails.
|
||||
REM
|
||||
REM Real install paths confirmed via 01-eDNC.ps1 (PXE preinstall) + a
|
||||
REM real shopfloor install log capture:
|
||||
REM C:\Program Files (x86)\DNC\Server Files\eMxInfo.txt
|
||||
REM C:\Program Files\DNC\Server Files\eMxInfo.txt
|
||||
REM Both required because mxTransactionDll.dll references both.
|
||||
REM
|
||||
REM Earlier versions wrote to C:\Program Files\eDNC\eMxInfo.txt - that
|
||||
REM path doesn't exist on disk; the eDNC product creates \DNC\ (no 'e')
|
||||
REM with a "Server Files" subdir. Corrected 2026-04-28.
|
||||
|
||||
set "SRC=%~dp0eMxInfo.txt"
|
||||
if not exist "%SRC%" (
|
||||
echo Install-eMxInfo: source file not found at %SRC%
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
set "DST64=C:\Program Files\DNC\Server Files"
|
||||
set "DST86=C:\Program Files (x86)\DNC\Server Files"
|
||||
|
||||
if not exist "%DST64%\" mkdir "%DST64%" 2>nul
|
||||
if not exist "%DST86%\" mkdir "%DST86%" 2>nul
|
||||
|
||||
copy /Y "%SRC%" "%DST64%\eMxInfo.txt" >nul || exit /b 2
|
||||
copy /Y "%SRC%" "%DST86%\eMxInfo.txt" >nul || exit /b 3
|
||||
|
||||
echo Install-eMxInfo: deployed eMxInfo.txt to both DNC\Server Files paths
|
||||
exit /b 0
|
||||
@@ -0,0 +1,383 @@
|
||||
# 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 = 'V:'
|
||||
)
|
||||
$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)
|
||||
}
|
||||
|
||||
Log "Mounting share with SFLD creds: $BackupShareRoot -> W:"
|
||||
$shareMounted = $false
|
||||
$sw = [Diagnostics.Stopwatch]::StartNew()
|
||||
while ($sw.Elapsed.TotalSeconds -lt $ShareTimeoutSec) {
|
||||
if (Mount-SFLDShare -SharePath $BackupShareRoot -DriveLetter 'W:') {
|
||||
$shareMounted = $true
|
||||
break
|
||||
}
|
||||
Start-Sleep -Seconds $SharePollSec
|
||||
}
|
||||
$sw.Stop()
|
||||
if ($shareMounted) {
|
||||
Log ("Share mounted as W: 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 W: (authenticated as SFLD user) so
|
||||
# Test-Path returns the truth, not access-denied-False.
|
||||
$bayDir = Join-Path 'W:\' $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 W: /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 W: /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 W: /delete /y 2>$null | Out-Null
|
||||
|
||||
Log 'Exit 0.'
|
||||
Log '==============================================='
|
||||
exit 0
|
||||
Reference in New Issue
Block a user