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:
cproudlock
2026-05-04 08:49:43 -04:00
parent 64169819b3
commit ce3fbf5a28
17 changed files with 13413 additions and 294 deletions

3
.gitignore vendored
View File

@@ -77,3 +77,6 @@ secrets.yml
*_secret
*_secrets
credentials.json
# Pre-staged binary (142 MB) - track via LFS or stage on PXE server, not in regular git
playbook/shopfloor-setup/Shopfloor/PrinterInstallerMap.exe

View File

@@ -0,0 +1,239 @@
# Shopfloor Machine PC Imaging Guide
Step-by-step for imaging a new (or replacement) shopfloor PC that will sit at a CNC machine and run UDC, eDNC, NTLARS, MTConnect, and the standard shopfloor toolset.
## Prerequisites
- PC connected to the **PXE switch** (not the production network yet)
- USB mouse + keyboard connected
- PXE server is running and reachable (verify by pinging `10.9.100.1` from another PC on the same switch)
- **Target machine number** known (e.g., `7605`) — you can enter it at PXE time, or use `9999` as a placeholder if the PC will be configured at the bay later
- **ARTS Lockdown request submitted** for this PC (or know that you'll submit one mid-imaging)
---
## Step 1: BIOS Configuration
1. Plug the PC into the **KVM**.
2. Power on the PC and begin **tapping F12** to bring up the One-Time-Boot menu.
3. Select **BIOS Setup**.
4. Toggle **Advanced Setup** to **ENABLED**.
5. Click **Boot Configuration**:
- Verify **Enable Secure Boot** is **ENABLED**
- Verify **Enable Microsoft UEFI CA** is **ENABLED**
6. Click **Storage** and verify **SATA/NVMe Operation** is set to **AHCI/NVMe**.
7. **If this is a Precision Tower**: click **Security** and **ENABLE "Start Data Wipe"** (wipes existing data on next boot).
8. Click **Apply Changes**, then **Exit**.
---
## Step 2: PXE Boot
1. Begin **tapping F12** again to return to the One-Time-Boot menu.
2. Verify the **network cable is connected to the PXE Server's isolated switch** (NOT the production network).
3. From the One-Time-Boot menu, select **ONBOARD NIC (IPV4)**.
4. Once the PXE Boot menu appears, select **Windows PE (Image Deployment)**.
5. WinPE launches with a command prompt that **automatically updates the BIOS to the latest version** before prompting you to select the image type.
---
## Step 3: Image + Enrollment Selection
1. **WinPE Setup Menu**: select `3. GEA Shopfloor`.
2. **GCCH Enrollment Profile**: select `1. No Office` (machine PCs don't need Office).
3. **Shopfloor PC Type**: select `6. Standard`.
4. **Standard PC Sub-Type**: select `1. Machine`.
5. **Machine number prompt**:
- If the PC's target bay is known: type the machine number (e.g., `7605`) and press Enter.
- If the bay isn't known yet: just press Enter to use placeholder `9999`. You'll set the real number after the PC is physically placed at the bay (see Step 9).
---
## Step 4: Imaging (Automated Phase)
Once GE Image Setup launches:
1. Click **Start**.
2. The process runs unattended through:
- Disk partition + Windows install
- PreInstall apps (Oracle Client 11.2, OpenText HostExplorer, VC++ Redists, eDNC if Standard-Machine, UDC, etc.)
- GE-Enforce framework registration
- First reboot
3. Note the **Serial Number** from the screen — log it in your tracking sheet.
4. The PC reboots and auto-logs in as `SupportUser`. The "Shopfloor Intune Sync" PowerShell window opens automatically.
**This whole phase takes ~20-40 minutes** depending on hardware.
---
## Step 5: Monitor Intune Enrollment
Once the **Shopfloor Intune Sync** window is open, you'll see a 5-phase status table that refreshes every 30 seconds:
```
1. Intune Registration [WAITING/IN PROGRESS/COMPLETE]
2. Device Configuration [WAITING/IN PROGRESS/COMPLETE]
3. Software Deployment [WAITING/IN PROGRESS/COMPLETE]
4. Credential Setup [WAITING/IN PROGRESS/COMPLETE]
5. Lockdown [WAITING/IN PROGRESS/COMPLETE]
```
Below the table, an **Intune Device ID** + QR code appears. Scan the QR with your phone to copy the device ID into your ARTS Lockdown request.
### What to do at each phase
- **Phase 1 → COMPLETE**: a `>> Select Device Category in Intune portal` hint appears. **Action**: in Intune, set the Device Category to `Shopfloor` (or whatever your site uses).
- **Phase 2 → COMPLETE**: just keep watching.
- **Phase 3 → IN PROGRESS forever**: known issue — the DSC `device-config.yaml` download is currently failing with a 403. **It does NOT block setup-complete** — Phases 4 and 5 are independent. Skip ahead.
- **Phase 4 → COMPLETE**: SFLD share creds landed in registry. A `>> Initiate ARTS Lockdown request` hint appears if you haven't already.
- **Phase 5 → COMPLETE**: lockdown applied via Intune Remediation. The script auto-fires "Setup Complete" and reboots the PC.
If Phase 5 stays WAITING for >30 minutes after Phase 4 completes, see Step 6.
---
## Step 6: Force Lockdown (only if needed)
If Phase 5 is stuck WAITING for 30+ minutes after Phase 4 completed AND the ARTS Lockdown request is approved:
1. Open an elevated cmd or PowerShell.
2. Run:
```
C:\Enrollment\shopfloor-setup\Shopfloor\Force-Lockdown.bat
```
3. It self-elevates via UAC, prompts for confirmation:
```
Type YES (uppercase) to confirm ARTS request is in place: YES
```
4. The script runs `sfld_autologon.ps1`, flips Winlogon to ShopFloor autologon, and writes `C:\Enrollment\force-lockdown-applied.txt` on success.
5. Within 30 seconds, the Intune Sync window's Phase 5 flips to COMPLETE → "Setup Complete" → reboot.
**WARNING**: Do NOT run Force-Lockdown without an approved ARTS request. It bypasses the normal Intune Lockdown-group push and will be flagged in the audit trail.
---
## Step 7: Post-Reboot — ShopFloor Autologon Phase
After the lockdown reboot, the PC auto-logs in as `ShopFloor` (instead of SupportUser).
What happens automatically:
1. WiFi profile (`AESFMA` SSID) lands via Intune.
2. PC connects to AESFMA.
3. `S:` drive maps to `\\tsgwp00525.wjs.geaerospace.net\shared`.
4. **GE Shopfloor Machine Apps Enforce** scheduled task fires on logon.
5. Manifest engine reads `\\tsgwp00525\...\common\manifest.json` AND `\\tsgwp00525\...\standard-machine\manifest.json`, evaluates each app entry against current state, runs installer if not detected.
6. Apps installed/verified: Adobe Acrobat Reader DC, WJF Defect Tracker, 3OF9 barcode font, Edge IE-Mode site list + policy, VNC firewall rule, Oracle Client 11.2, OpenText HostExplorer ShopFloor, UDC, eDNC + NTLARS, eMxInfo.txt, MTConnect Fanuc/OKUMA/Makino/eDNC variants (per machine number).
7. May take 5-15 minutes on first logon (cold app installs); subsequent logons skip-and-validate in <30 seconds.
You can watch progress in `C:\GE Aerospace\machineapps-enforce.log`.
---
## Step 8: Move to the Bay
Physically move the PC to its target machine. Plug into the production ethernet (NOT the PXE switch).
If the PC doesn't have an assigned machine number yet, or if you used `9999` placeholder at PXE time, continue to Step 9.
If you entered the real machine number at PXE time, Configure-PC.ps1 already wrote it to UDC, eDNC, the DNC registry, and MTConnect Devices.xml automatically — **skip to Step 10**.
---
## Step 9: Set Machine Number (only if 9999 placeholder was used)
1. Log in as **SupportUser** (admin).
2. Run from Desktop or Start Menu:
```
Set Machine Number.lnk
```
(which calls `C:\Enrollment\shopfloor-setup\Standard\Set-MachineNumber.ps1`)
3. Type the new machine number (digits only) when the GUI prompts.
4. Click OK. The script:
- Stops UDC, writes the new number to UDC settings JSON, relaunches UDC
- Writes the new number to eDNC registry (`HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General\MachineNo`)
- Pulls the per-machine eDNC `.reg` backup from `\\tsgwp00525\...\ntlars-backups\<num>.reg` (restores eFocas/PPDCS/Hssb config for that machine)
- Updates MTConnect `Devices.xml` for any installed agent (Fanuc/Okuma/Makino/eDNC) and restarts the agent service
5. A summary dialog confirms what was updated.
---
## Step 10: Verify the Machine
Before signing off, confirm the PC is healthy:
```powershell
# Service health
Get-Service | Where-Object { $_.Name -match '^(MTConnect|Makino|MakinoMTConnect|MTConnect eDNC|MTConnect Adapter|UDC|DNC)' } |
Format-Table Name, Status, StartType -AutoSize
# Machine number persisted everywhere
"UDC: $((Get-Content 'C:\ProgramData\UDC\udc_settings.json' -Raw | ConvertFrom-Json).GeneralSettings.MachineNumber)"
"eDNC: $((Get-ItemProperty 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General' -Name MachineNo).MachineNo)"
# MTConnect HTTP probe (depends on variant - port 5000 for Fanuc/OKUMA, 5001 for eDNC, 5005 for UDC)
Invoke-WebRequest 'http://localhost:5000/probe' -UseBasicParsing -TimeoutSec 3 | Select StatusCode
# Manifest engine ran cleanly
Get-Content 'C:\GE Aerospace\machineapps-enforce.log' -Tail 20
```
Expected healthy state:
- All MTConnect/UDC/DNC services: **Running** + **Auto** start type
- UDC + eDNC machine numbers: **match the assigned bay**
- HTTP probe: **HTTP 200** with a `<MTConnectDevices>` XML response
- Manifest enforce log: ends with `evaluation complete: N entries, 0 failures` (or similar)
---
## Troubleshooting
### Intune Sync window closes by itself
It writes `C:\Logs\SFLD\sync_intune_transcript.txt` continuously. Open that log to see what it last reported. Re-launch via:
```
C:\Enrollment\shopfloor-setup\Shopfloor\sync_intune.bat
```
### Phase 3 stuck at IN PROGRESS
Known issue — the DSC blob download is 403'ing right now. Doesn't block setup-complete. If you need DSC's wallpaper / start menu pins / FileSystem actions, escalate to IT to fix the SAS token or storage account firewall on `geasfldwestjefferson`. Until then, those visual customizations won't appear — operators won't notice if the start menu pins are absent because they're not the primary workflow.
### Phase 5 (Lockdown) stays WAITING after 30 minutes
ARTS request is probably still pending. Confirm approval, then run Force-Lockdown.bat (Step 6).
### Manifest engine logs show "DllNotFoundException" or "share not reachable"
PC isn't on AESFMA WiFi yet (or WiFi profile hasn't pushed). Wait 5-10 minutes after the post-lockdown reboot. Verify:
```powershell
(Get-NetConnectionProfile).Name
Test-Path '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\common\manifest.json'
```
If `Test-Path` returns False, WiFi/auth isn't ready. If True, kick the manifest engine manually:
```powershell
Start-ScheduledTask -TaskName 'GE Shopfloor Machine Apps Enforce'
```
### MTConnect not running after machine-number set
The wrapper logs land at `C:\GE Aerospace\mtc-install-runservice-batconvert.log`. Common causes: pre-existing Windows Firewall Block rule (rare), Mark-of-the-Web on copied EXEs (the wrapper's Unblock-File sweep handles this), or the bundle isn't on the SFLD share for this variant. Open the log and grep for `ERROR`.
### Configure-PC machine-number GUI doesn't open
The script needs a desktop session. Won't run via WinRM/SSH/non-interactive. Make sure you're logged in at the console as SupportUser.
---
## Reference
- **PXE server**: `10.9.100.1`
- **SFLD share**: `\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\`
- **Manifest engine log**: `C:\GE Aerospace\machineapps-enforce.log`
- **Intune sync transcript**: `C:\Logs\SFLD\sync_intune_transcript.txt`
- **DSC logs**: `C:\Logs\SFLD\` (DSCDeployment.log, DSCInstall.log, version.txt)
- **Per-app install logs**: `C:\Logs\SFLD\Install-*.log`
- **Force-Lockdown marker**: `C:\Enrollment\force-lockdown-applied.txt`
- **Set-MachineNumber script**: `C:\Enrollment\shopfloor-setup\Standard\Set-MachineNumber.ps1`

View File

@@ -1,148 +0,0 @@
@echo off
REM check-bios.cmd - Check and apply Dell BIOS update from WinPE x64
REM Sets BIOS_STATUS for startnet.cmd menu display
set "BIOSDIR=%~dp0"
set "FLASH=%BIOSDIR%Flash64W.exe"
set "MANIFEST=%BIOSDIR%models.txt"
if exist "%FLASH%" goto :flash_ok
echo Flash64W.exe not found, skipping BIOS check.
set "BIOS_STATUS=Skipped (Flash64W.exe missing)"
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"
)
for /f "tokens=*" %%a in ("%SYSMODEL%") do set "SYSMODEL=%%a"
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 ---
set BIOSVER=
for /f "skip=1 tokens=*" %%V in ('wmic bios get smbiosbiosversion 2^>NUL') do (
if not defined BIOSVER set "BIOSVER=%%V"
)
for /f "tokens=*" %%a in ("%BIOSVER%") do set "BIOSVER=%%a"
echo Current BIOS: %BIOSVER%
REM --- Read manifest and find matching BIOS file ---
set BIOSFILE=
set TARGETVER=
for /f "usebackq eol=# tokens=1,2,3 delims=|" %%A in ("%MANIFEST%") do (
echo "%SYSMODEL%" | find /I "%%A" >NUL
if not errorlevel 1 (
set "BIOSFILE=%%B"
set "TARGETVER=%%C"
goto :found_bios
)
)
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%" 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 ---
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)...
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" 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%.
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
echo.
echo ========================================
echo BIOS update staged successfully.
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
:compare_versions
set "VERCMP=equal"
set "_CV=%~1"
set "_TV=%~2"
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
)
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 )
if %C2% LSS %T2% ( set "VERCMP=older" & goto :eof )
if %C3% GTR %T3% ( set "VERCMP=newer" & goto :eof )
if %C3% LSS %T3% ( set "VERCMP=older" & goto :eof )
set "VERCMP=equal"
goto :eof

View File

@@ -1,43 +1,30 @@
# migrate-to-wifi.ps1 - Invoked by FlatUnattendW10-shopfloor.xml as Order 5
# during first logon, right after wait-for-internet.ps1 and right before
# GCCH enrollment. Moves the machine off wired onto WiFi for the rest of
# the imaging chain so the PXE ethernet cable can be safely disconnected.
# migrate-to-wifi.ps1 - No-op as of 2026-04-24.
#
# Gated: if there is no physical Wi-Fi adapter on the machine (tower /
# desktop case), the whole migration is a no-op. Previously this step
# disabled all wired adapters unconditionally and then waited for WiFi
# internet that could never arrive on towers, hanging first logon forever.
# Previously this disabled all wired NICs at first logon to keep PPKG /
# Intune enrollment routing internet traffic via WiFi. The wired NIC was
# preferred by Windows because the PXE dnsmasq was handing out a default
# gateway (dhcp-option=3,10.9.100.1) which Windows installed as a default
# route, and the lower interface metric of wired beat WiFi. Internet-bound
# traffic then black-holed at 10.9.100.1 (the PXE server, which doesn't
# forward).
#
# That root cause was fixed by removing the dhcp-option=3 and =6 lines
# from /etc/dnsmasq.conf on the PXE server. Without an advertised gateway
# on the PXE side, Windows can't add a default route via wired, so all
# internet traffic uses WiFi by default and the wired NIC stays harmless
# for same-subnet PXE/SMB traffic to 10.9.100.1.
#
# Side effect of the original behavior was an eDNC race: eDNC autostart
# would fire while the wired NIC was still disabled and hit WSAEINVAL
# (Winsock 10022) trying to bind to a non-existent local IP, looping its
# retry timer until a SYSTEM task re-enabled the NIC after SFLD creds
# landed (often ~30+ min later). Keeping the NIC up the whole time
# eliminates that race.
#
# Kept as a no-op file (instead of removed) so the unattend XML's Order 5
# RunSynchronousCommand entry does not need to be re-numbered. If the
# dhcp-option lines ever come back, this can be reverted to the disable
# logic by restoring from git.
$wifi = Get-NetAdapter -Physical -ErrorAction SilentlyContinue |
Where-Object { $_.InterfaceDescription -match 'Wi-?Fi|Wireless|WLAN|802\.11' }
if (-not $wifi) {
Write-Host 'No WiFi adapter - staying on ethernet.' -ForegroundColor Cyan
exit 0
}
Get-NetAdapter -Physical |
Where-Object { $_.InterfaceDescription -notmatch 'Wi-?Fi|Wireless|WLAN|802\.11' } |
Disable-NetAdapter -Confirm:$false
$deadline = (Get-Date).AddMinutes(5)
$ok = $false
while ((Get-Date) -lt $deadline) {
try {
if (Test-NetConnection -ComputerName login.microsoftonline.us -Port 443 -InformationLevel Quiet -WarningAction SilentlyContinue) {
$ok = $true
break
}
} catch {}
Start-Sleep -Seconds 5
}
if ($ok) {
Write-Host 'Internet confirmed over WiFi.' -ForegroundColor Green
} else {
Write-Host 'WiFi internet timeout - re-enabling ethernet.' -ForegroundColor Yellow
Get-NetAdapter -Physical |
Where-Object { $_.InterfaceDescription -notmatch 'Wi-?Fi|Wireless|WLAN|802\.11' } |
Enable-NetAdapter -Confirm:$false
}
Write-Host 'migrate-to-wifi.ps1: no-op (wired NIC kept enabled).'
exit 0

View File

@@ -65,6 +65,41 @@ set RC=%ERRORLEVEL%
echo [%STAMP%] OUI exit code: %RC% >> "%LOG%"
REM --- Drop GE-customized tnsnames.ora / sqlnet.ora / ldap.ora into ORACLE_HOME.
REM The zip ships these in client\ora\ but Oracle's setup.exe does not consume
REM them - that copy is the GE Wise wrapper's job, which we bypass. Without
REM these, every Oracle-dependent app (eDNC/UDC/NTLARS/CMM tooling) fails with
REM ORA-12154 "TNS could not resolve the connect identifier specified".
REM
REM Per-file override: if %~dp0<name>.ora exists (deployed alongside this
REM wrapper on the PXE server / SFLD share), use it instead of the bundled
REM copy. Lets us push an updated tnsnames.ora without repackaging the zip.
REM
REM Only run when OUI succeeded (RC 0 or 3). On failure ORACLE_HOME may not
REM be fully populated and the copy targets may not exist.
if %RC%==0 goto :do_ora_copy
if %RC%==3 goto :do_ora_copy
goto :skip_ora_copy
:do_ora_copy
set "ORA_DST=C:\Apps\product\11.2.0\client_1\network\admin"
if not exist "%ORA_DST%" (
echo [%STAMP%] WARN: %ORA_DST% does not exist after OUI - skipping .ora copy >> "%LOG%"
goto :skip_ora_copy
)
for %%F in (tnsnames.ora sqlnet.ora ldap.ora) do (
set "ORA_SRC=%~dp0%%F"
if not exist "!ORA_SRC!" set "ORA_SRC=%CLIENT_DIR%\ora\%%F"
if exist "!ORA_SRC!" (
echo [%STAMP%] Copying %%F from !ORA_SRC! to %ORA_DST%\%%F >> "%LOG%"
copy /Y "!ORA_SRC!" "%ORA_DST%\%%F" >> "%LOG%" 2>&1
) else (
echo [%STAMP%] WARN: %%F not found in either %~dp0 or %CLIENT_DIR%\ora >> "%LOG%"
)
)
:skip_ora_copy
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

File diff suppressed because it is too large Load Diff

View File

@@ -148,8 +148,19 @@
interface={{ pxe_iface }}
bind-interfaces
dhcp-range=10.9.100.10,10.9.100.100,12h
dhcp-option=3,10.9.100.1
dhcp-option=6,8.8.8.8
# No default gateway (option 3) and no DNS (option 6) handed out:
# the PXE network is isolated and the PXE server does not forward
# internet traffic. Previously we set both, which made imaged PCs
# add a default route via 10.9.100.1 and prefer it over WiFi (lower
# interface metric). PPKG / Intune enrollment then black-holed
# internet-bound traffic. The fix used to be migrate-to-wifi.ps1
# disabling the wired NIC during first-logon, which created an
# eDNC race (10022 socket errors until the SYSTEM task re-enabled
# the wired NIC much later). Removing these options entirely lets
# Windows route internet via WiFi and same-subnet PXE/SMB traffic
# via wired, no migration script needed.
# dhcp-option=3,10.9.100.1
# dhcp-option=6,8.8.8.8
enable-tftp
tftp-root={{ tftp_dir }}
dhcp-boot=ipxe.efi
@@ -448,6 +459,18 @@
mode: '0755'
ignore_errors: yes
# Per-file overrides for the .ora config dropped by the wrapper post-install.
# The zip's client/ora/ contains dated defaults; shipping an updated
# tnsnames.ora alongside the wrapper lets us refresh the DB catalog without
# repackaging the 686 MB zip. sqlnet.ora and ldap.ora can be added here the
# same way if they ever need to diverge from the zip's bundled copies.
- name: "Deploy updated tnsnames.ora override to pre-install/installers/oracle/"
copy:
src: "{{ usb_mount }}/preinstall/oracle/tnsnames.ora"
dest: /srv/samba/enrollment/pre-install/installers/oracle/tnsnames.ora
mode: '0644'
ignore_errors: yes
- name: "Deploy Oracle 11.2 zip (686 MB) from USB to pre-install/installers/oracle/"
shell: >
if [ -f "{{ usb_root }}/oracle/Oracle_OracleDatabase_11r2_V03.zip" ]; then
@@ -459,21 +482,40 @@
fi
ignore_errors: yes
- name: "Deploy BIOS check script and manifest to pre-install/bios/"
- name: "Ensure winpeapps/_shared/BIOS directory exists"
file:
path: /srv/samba/winpeapps/_shared/BIOS
state: directory
mode: '0755'
- name: "Deploy BIOS check script + manifest to winpeapps/_shared/BIOS/"
# Path matches what startnet.cmd reads at WinPE boot:
# net use B: \\10.9.100.1\winpeapps\_shared
# if exist B:\BIOS\check-bios.cmd ...
# Earlier deploy targeted enrollment/pre-install/bios/ (different share)
# which startnet.cmd never read, so BIOS_STATUS perma-stuck on
# "No BIOS check (share unavailable)". Corrected 2026-04-28.
copy:
src: "{{ usb_mount }}/shopfloor-setup/BIOS/{{ item }}"
dest: "/srv/samba/enrollment/pre-install/bios/{{ item }}"
dest: "/srv/samba/winpeapps/_shared/BIOS/{{ item }}"
mode: '0644'
loop:
- check-bios.cmd
- models.txt
ignore_errors: yes
- name: "Deploy BIOS update binaries from USB to pre-install/bios/"
- name: "Deploy Dell Flash64W.exe to winpeapps/_shared/BIOS/"
copy:
src: "{{ usb_root }}/bios/Flash64W.exe"
dest: /srv/samba/winpeapps/_shared/BIOS/Flash64W.exe
mode: '0644'
ignore_errors: yes
- name: "Deploy BIOS update binaries from USB to winpeapps/_shared/BIOS/"
shell: >
if [ -d "{{ usb_root }}/bios" ]; then
cp -f {{ usb_root }}/bios/*.exe /srv/samba/enrollment/pre-install/bios/ 2>/dev/null || true
count=$(find /srv/samba/enrollment/pre-install/bios -name '*.exe' | wc -l)
cp -f {{ usb_root }}/bios/*.exe /srv/samba/winpeapps/_shared/BIOS/ 2>/dev/null || true
count=$(find /srv/samba/winpeapps/_shared/BIOS -name '*.exe' | wc -l)
echo "Deployed $count BIOS binaries"
else
echo "No bios/ on USB - skipping"

View File

@@ -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 )

View File

@@ -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

View File

@@ -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

View File

@@ -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%

View 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"

View File

@@ -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

View 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

View File

@@ -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" } }

View File

@@ -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

View File

@@ -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