diff --git a/playbook/shopfloor-setup/Verify-And-Heal-Staging.bat b/playbook/shopfloor-setup/Verify-And-Heal-Staging.bat new file mode 100644 index 0000000..48c9417 --- /dev/null +++ b/playbook/shopfloor-setup/Verify-And-Heal-Staging.bat @@ -0,0 +1,27 @@ +@echo off +REM ========================================================================== +REM Verify-And-Heal-Staging.bat - check every imaging payload arrived on this PC +REM and re-pull whatever is missing from the enrollment share. +REM +REM Usage (run on the PC): +REM Verify-And-Heal-Staging.bat verify + heal anything missing +REM Verify-And-Heal-Staging.bat /verifyonly report only, do not pull +REM ========================================================================== +setlocal EnableDelayedExpansion + +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo Requesting administrator elevation... + powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'" + exit /b +) + +set "PS=%~dp0Verify-And-Heal-Staging.ps1" +set "ARGS=" +if /I "%~1"=="/verifyonly" set "ARGS=-VerifyOnly" + +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" %ARGS% +echo. +echo Exit code: %errorlevel% (0=all present/healed, 1=still missing) +pause +endlocal diff --git a/playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1 b/playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1 new file mode 100644 index 0000000..f9e2bd0 --- /dev/null +++ b/playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1 @@ -0,0 +1,155 @@ +<# +Verify-And-Heal-Staging.ps1 + +Post-boot check that every payload the imaging flow is supposed to stage onto a +shopfloor PC actually arrived - and re-pull (heal) anything missing from the +enrollment share. Runs in full Windows (reliable network), so it is immune to the +WinPE samba-idle-drop that loses copies during the WIM apply. + +Covers the generic Fetch payload (shopfloor-setup tree + preinstall bundle) AND +the heavy per-type payload that Fetch-StagingPayload does NOT pull today: the CMM +bundle (C:\CMM-Install) and the selected bay's backup set +(C:\CMM-Install\backups\). That is the one that silently goes missing when +WinPE staging runs out of time before reboot. + +Designed to be: + - run manually on a problem PC (Verify-And-Heal-Staging.bat), or + - called from the pre-install phase before 00-PreInstall-MachineApps so a bay is + never left under-provisioned. + +Idempotent. Verifies first; only heals what is actually missing/empty. Heals use +/R:3 /W:5 (resilient), not the WinPE fail-fast /R:1 /W:1. + +Share + creds: read from C:\Enrollment\fetch-source.txt (line1=UNC, line2=user, +line3=pass) - same file Fetch-StagingPayload uses - else the defaults below. + +Run as administrator. Exit 0 = everything present or healed; 1 = something still +missing after heal attempts (read the table). +#> +param( + [string]$ShareUnc, + [string]$ShareUser, + [string]$SharePass, + [switch]$VerifyOnly # report only, do not heal +) +$ErrorActionPreference = 'Continue' + +$ts = Get-Date -Format 'yyyyMMdd-HHmmss' +$logDir = 'C:\Logs\Fetch' +New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null +$log = Join-Path $logDir "verify-heal-$ts.log" +function Log($m,$lvl='INFO'){ $line="[$(Get-Date -Format 'HH:mm:ss')] [$lvl] $m"; Write-Host $line; Add-Content -Path $log -Value $line -EA SilentlyContinue } + +# --- share + creds (mirror Fetch-StagingPayload) --- +$defUnc='\\172.16.9.1\enrollment'; $defUser='pxe-upload'; $defPass='pxe' +$srcFile='C:\Enrollment\fetch-source.txt' +if ((-not $ShareUnc) -and (Test-Path -LiteralPath $srcFile)) { + $l=@(Get-Content -LiteralPath $srcFile -EA SilentlyContinue) + if ($l.Count -ge 1 -and $l[0].Trim()) { $ShareUnc=$l[0].Trim() } + if ($l.Count -ge 2 -and $l[1].Trim()) { $ShareUser=$l[1].Trim() } + if ($l.Count -ge 3 -and $l[2].Trim()) { $SharePass=$l[2].Trim() } +} +if (-not $ShareUnc) { $ShareUnc=$defUnc } +if (-not $ShareUser) { $ShareUser=$defUser } +if (-not $SharePass) { $SharePass=$defPass } + +# --- identity --- +function ReadTxt($p){ if (Test-Path -LiteralPath $p) { (Get-Content -LiteralPath $p -First 1 -EA 0).Trim() } else { '' } } +$pcType = ReadTxt 'C:\Enrollment\pc-type.txt' +$cmmid = ReadTxt 'C:\Enrollment\cmm\cmmid.txt' + +Log "=== Verify-And-Heal-Staging ===" +Log "share=$ShareUnc user=$ShareUser pcType=$(if($pcType){$pcType}else{'(none)'}) cmmid=$(if($cmmid){$cmmid}else{'(none)'}) verifyOnly=$VerifyOnly" + +# --- expected payload manifest ------------------------------------------------- +# Each: Label, Src (under share), Dst, Mode (File|Dir), Verify (path that must +# exist to count as present), Optional (missing-and-no-source is not a failure), +# Files (for Mode=File), Xd (robocopy /XD dirs to exclude on heal). +$items = New-Object System.Collections.Generic.List[object] +function Add-Item($Label,$Src,$Dst,$Mode,$Verify,$Files=$null,$Optional=$false,$Xd=$null){ + $items.Add([pscustomobject]@{Label=$Label;Src=$Src;Dst=$Dst;Mode=$Mode;Verify=$Verify;Files=$Files;Optional=$Optional;Xd=$Xd}) +} +$ENR='C:\Enrollment'; $SFD='C:\Enrollment\shopfloor-setup'; $PIN='C:\PreInstall' +Add-Item 'Run-ShopfloorSetup.ps1' 'shopfloor-setup' $ENR 'File' (Join-Path $ENR 'Run-ShopfloorSetup.ps1') @('Run-ShopfloorSetup.ps1') +Add-Item 'Shopfloor baseline' 'shopfloor-setup\Shopfloor' (Join-Path $SFD 'Shopfloor') 'Dir' (Join-Path $SFD 'Shopfloor') +Add-Item 'common' 'shopfloor-setup\common' (Join-Path $SFD 'common') 'Dir' (Join-Path $SFD 'common') +Add-Item '_ntlars-backups' 'shopfloor-setup\_ntlars-backups' (Join-Path $SFD '_ntlars-backups') 'Dir' (Join-Path $SFD '_ntlars-backups') $null $true +if ($pcType) { + Add-Item "type:$pcType" "shopfloor-setup\$pcType" (Join-Path $SFD $pcType) 'Dir' (Join-Path $SFD $pcType) +} +Add-Item 'preinstall.json' 'pre-install' $PIN 'File' (Join-Path $PIN 'preinstall.json') @('preinstall.json') +Add-Item 'preinstall installers' 'pre-install\installers' (Join-Path $PIN 'installers') 'Dir' (Join-Path $PIN 'installers') +Add-Item 'udc-backups' 'pre-install\udc-backups' (Join-Path $PIN 'udc-backups') 'Dir' (Join-Path $PIN 'udc-backups') $null $true +# --- heavy CMM payload (the gap) --- +if ($pcType -eq 'gea-shopfloor-cmm') { + Add-Item 'CMM bundle' 'installers-post\cmm' 'C:\CMM-Install' 'Dir' 'C:\CMM-Install\cmm-manifest.json' $null $false 'backups' + if ($cmmid) { + Add-Item "CMM backup ($cmmid)" "installers-post\cmm\backups\$cmmid" "C:\CMM-Install\backups\$cmmid" 'Dir' "C:\CMM-Install\backups\$cmmid" $null $true + } +} + +# --- present check ------------------------------------------------------------- +function Test-Present($it){ + if (-not (Test-Path -LiteralPath $it.Verify)) { return $false } + if ($it.Mode -eq 'Dir') { + $n = @(Get-ChildItem -LiteralPath $it.Verify -Recurse -File -EA SilentlyContinue).Count + return ($n -gt 0) + } + return $true +} + +# --- mount share fresh (only if we will heal) --------------------------------- +$drive='Z:'; $mounted=$false +function Mount-Share { & net use $drive /delete /y 2>$null | Out-Null; & net use $drive $ShareUnc /user:$ShareUser $SharePass /persistent:no 2>&1 | Out-Null; return ($LASTEXITCODE -eq 0) } + +# --- first pass: verify -------------------------------------------------------- +$report = New-Object System.Collections.Generic.List[object] +$missing = New-Object System.Collections.Generic.List[object] +foreach ($it in $items) { + if (Test-Present $it) { $report.Add([pscustomobject]@{Item=$it.Label;Status='PRESENT'}) } + else { $missing.Add($it); $report.Add([pscustomobject]@{Item=$it.Label;Status=$(if($it.Optional){'MISSING(opt)'}else{'MISSING'})}) } +} + +# --- heal missing -------------------------------------------------------------- +if ($missing.Count -gt 0 -and -not $VerifyOnly) { + Log "$($missing.Count) item(s) missing - mounting share to heal" + for ($a=1;$a -le 5 -and -not $mounted;$a++){ if (Mount-Share){$mounted=$true;Log "Mounted $ShareUnc as $drive"} else {Log "mount attempt $a failed - 10s" 'WARN'; Start-Sleep 10} } + if (-not $mounted) { Log "Could not mount share - cannot heal. $($missing.Count) item(s) remain missing." 'ERROR' } + else { + foreach ($it in $missing) { + $src = Join-Path $drive $it.Src + if (-not (Test-Path -LiteralPath $src)) { + Log "[heal SKIP] $($it.Label): not on share ($src)$(if($it.Optional){' - optional'})" $(if($it.Optional){'INFO'}else{'WARN'}) + ($report | Where-Object { $_.Item -eq $it.Label })[0].Status = $(if($it.Optional){'ABSENT(opt)'}else{'NO-SOURCE'}) + continue + } + if (-not (Test-Path -LiteralPath $it.Dst)) { New-Item -ItemType Directory -Path $it.Dst -Force | Out-Null } + $args=@($src,$it.Dst) + if ($it.Mode -eq 'Dir') { $args+='/E' } else { $args+=$it.Files } + if ($it.Xd) { $args+=@('/XD',(Join-Path $src $it.Xd)) } + $args+=@('/R:3','/W:5','/NFL','/NDL','/NP') + Log "[heal] $($it.Label): robocopy $src -> $($it.Dst)" + & robocopy @args | Out-Null + $rc=$LASTEXITCODE + Start-Sleep 1 + $ok = Test-Present $it + ($report | Where-Object { $_.Item -eq $it.Label })[0].Status = $(if($ok){'HEALED'}elseif($rc -ge 8){'HEAL-FAIL'}else{'STILL-MISSING'}) + Log "[heal $(if($ok){'OK'}else{'FAIL'})] $($it.Label) robocopy exit=$rc present=$ok" + } + & net use $drive /delete /y 2>$null | Out-Null + } +} + +# --- report -------------------------------------------------------------------- +Log '================ STAGING VERIFY/HEAL REPORT ================' +foreach ($r in $report) { Log (" {0,-26} {1}" -f $r.Item, $r.Status) } +$bad = @($report | Where-Object { $_.Status -in @('MISSING','NO-SOURCE','HEAL-FAIL','STILL-MISSING') }) +if ($bad.Count -gt 0) { + Log "RESULT: $($bad.Count) REQUIRED item(s) still missing: $(($bad|ForEach-Object{$_.Item}) -join ', ')" 'ERROR' + Log "Log: $log" + exit 1 +} else { + Log 'RESULT: all required payloads present (or healed).' + Log "Log: $log" + exit 0 +} diff --git a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 index ca2b746..34a2618 100644 --- a/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 +++ b/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1 @@ -38,6 +38,12 @@ $ErrorActionPreference = 'Continue' # logged; manifests tagged with a newer MINOR are fine. # # Changelog: +# 2.6 - added _CmmVersion filter. Entry tagged _CmmVersion only applies when +# it equals C:\Enrollment\cmm\version.txt (the bay's resolved PC-DMIS +# version, written at imaging from cmm-bay-config.csv). Untagged entries +# always pass; missing/empty version file is a no-op (legacy install-all +# + non-CMM scopes unaffected). Lifted out of 09-Setup-CMM so the gate +# lives in one place both the imaging and enforce paths share. # 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest # entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI # successfully but the wrapper process never exits (waits on a @@ -58,7 +64,7 @@ $ErrorActionPreference = 'Continue' # 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types, # Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter $LIB_MANIFEST_MAJOR = 2 -$LIB_MANIFEST_MINOR = 5 +$LIB_MANIFEST_MINOR = 6 $logDir = Split-Path -Parent $LogFile if (-not (Test-Path $logDir)) { @@ -575,6 +581,42 @@ function Test-MachineNumberMatches { return $false } +# CMM PC-DMIS version filter. The bay's PC-DMIS version (2016/2019/2026) is +# resolved at imaging by resolve-cmm-bay-config.ps1 from cmm-bay-config.csv (the +# single bay -> version map) and persisted to C:\Enrollment\cmm\version.txt. An +# entry tagged _CmmVersion applies only when it equals that file; untagged +# entries (CLM, goCMM, Protect Viewer, DODA, the PDF converter) always pass. +# When the file is absent/empty - a bay imaged before the picker, or any +# non-CMM PC running a different scope - the filter is a no-op so every tagged +# entry passes. That preserves the legacy "install all versions" behavior for +# pre-picker bays and leaves non-CMM scopes untouched. +# +# This is the SINGLE place the version gate lives. Both the imaging path +# (09-Setup-CMM) and the runtime path (GE-Enforce) call this lib, so the gate +# cannot apply in one path and not the other. The 2016-installed-on-a-2019-bay +# bug was exactly that drift: the imaging path filtered by _CmmVersion but the +# enforce path did not, so enforce reinstalled every version it did not detect. +$script:_cachedCmmVersion = $null +$script:_cmmVersionRead = $false +function Get-CurrentCmmVersion { + if ($script:_cmmVersionRead) { return $script:_cachedCmmVersion } + $script:_cmmVersionRead = $true + $f = 'C:\Enrollment\cmm\version.txt' + if (Test-Path -LiteralPath $f) { + $v = (Get-Content -LiteralPath $f -First 1 -ErrorAction SilentlyContinue) + if ($v) { $script:_cachedCmmVersion = $v.Trim() } + } + return $script:_cachedCmmVersion +} + +function Test-CmmVersionMatches { + param($App) + if (-not $App._CmmVersion) { return $true } # untagged entry always applies + $myVer = Get-CurrentCmmVersion + if (-not $myVer) { return $true } # no resolved version -> legacy install-all + return ([string]$App._CmmVersion -ieq $myVer) +} + # --------------------------------------------------------------------------- # Main loop # --------------------------------------------------------------------------- @@ -614,6 +656,13 @@ foreach ($app in $config.Applications) { continue } + if (-not (Test-CmmVersionMatches -App $app)) { + $myVer = Get-CurrentCmmVersion + Write-InstallLog " _CmmVersion filter: entry targets $($app._CmmVersion) but bay version is $(if ($myVer) { $myVer } else { '(none)' }) - skipping" + $pcFiltered++ + continue + } + if (Test-AppInstalled -App $app) { Write-InstallLog ' Already installed at expected version - skipping' $skipped++ diff --git a/playbook/shopfloor-setup/gea-shopfloor-cmm/09-Setup-CMM.ps1 b/playbook/shopfloor-setup/gea-shopfloor-cmm/09-Setup-CMM.ps1 index 4fdb3ce..74e26dc 100644 --- a/playbook/shopfloor-setup/gea-shopfloor-cmm/09-Setup-CMM.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-cmm/09-Setup-CMM.ps1 @@ -124,42 +124,13 @@ else { if (Test-Path 'C:\Enrollment\pc-type.txt') { $pcType = (Get-Content 'C:\Enrollment\pc-type.txt' -First 1 -EA 0).Trim() } if (Test-Path 'C:\Enrollment\pc-subtype.txt') { $pcSubType = (Get-Content 'C:\Enrollment\pc-subtype.txt' -First 1 -EA 0).Trim() } - # Read resolved PC-DMIS version from bay-config (written by - # resolve-cmm-bay-config.ps1 via startnet.cmd). If missing, install all - # PC-DMIS versions (legacy behavior for bays imaged before the picker). - $cmmVersion = '' - $cmmVersionFile = 'C:\Enrollment\cmm\version.txt' - if (Test-Path -LiteralPath $cmmVersionFile) { - $cmmVersion = (Get-Content -LiteralPath $cmmVersionFile -First 1 -EA 0).Trim() - } - Write-CMMLog "Resolved CMM version: $(if ($cmmVersion) { $cmmVersion } else { '(none - installing all)' })" - - # Filter manifest: drop entries whose _CmmVersion doesn't match the - # resolved version. Entries without _CmmVersion always pass (CLM, goCMM, - # Protect Viewer, DODA). Write a temp filtered manifest for the lib. - if ($cmmVersion) { - try { - $cfg = Get-Content $stagingMani -Raw | ConvertFrom-Json - $filtered = @($cfg.Applications | Where-Object { - if (-not $_._CmmVersion) { return $true } - return ($_._CmmVersion -ieq $cmmVersion) - }) - $skipped = @($cfg.Applications | Where-Object { - $_._CmmVersion -and ($_._CmmVersion -ine $cmmVersion) - }) - foreach ($s in $skipped) { - Write-CMMLog " Skipping $($s.Name) (_CmmVersion=$($_._CmmVersion) != $cmmVersion)" - } - $cfg.Applications = $filtered - $filteredMani = Join-Path $stagingRoot 'cmm-manifest-filtered.json' - $cfg | ConvertTo-Json -Depth 10 | Set-Content -LiteralPath $filteredMani -Encoding UTF8 - Write-CMMLog "Filtered manifest: $($filtered.Count) entries (from $($filtered.Count + $skipped.Count))" - $stagingMani = $filteredMani - } catch { - Write-CMMLog "Version filter failed: $_ - using unfiltered manifest" 'WARN' - } - } - + # PC-DMIS version gating (drop entries whose _CmmVersion does not match the + # bay's C:\Enrollment\cmm\version.txt) is now owned by the shared lib + # Install-FromManifest.ps1 (>= 2.6). Both this imaging path and the runtime + # GE-Enforce path call that lib, so the gate is applied identically in one + # place and the two paths cannot drift. We pass the full manifest unfiltered + # and let the lib filter per entry. The bug this prevents: enforce lacked + # the gate and reinstalled the wrong PC-DMIS version on an already-imaged bay. Write-CMMLog "Running Install-FromManifest against $stagingRoot (PCType=$pcType, PCSubType=$pcSubType)" & $libSource -ManifestPath $stagingMani -InstallerRoot $stagingRoot -LogFile $logFile -PCType $pcType -PCSubType $pcSubType $rc = $LASTEXITCODE diff --git a/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Install-goCMMSettings.ps1 b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Install-goCMMSettings.ps1 index a38cba0..acabff1 100644 --- a/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Install-goCMMSettings.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Install-goCMMSettings.ps1 @@ -38,6 +38,25 @@ param( $DefaultRewrites = @( @{ From = 'rd.ds.ge.com'; To = 'wjs.geaerospace.net' } # WJ legacy domain -> new domain ) + +# ---------------------------------------------------------------------------- +# Part-group share host canonicalization. Bays were captured with three +# inconsistent forms of the share host in the goCMM 'Selected Part Group' / +# ApplicationSettings.xml : +# \\tsgwp00525\... (bare hostname - DNS-suffix dependent) +# \\tsgwp00525.rd.ds.ge.com\... (legacy GE corp domain - dead on the +# air-gapped shopfloor net) +# \\tsgwp00525.wjs.geaerospace.net\... (correct) +# The DefaultRewrites above only fix the middle form. goCMM DISPLAYS the part +# group from ApplicationSettings.xml, so the bare form survived into the UI. +# Pin every form to the FQDN in BOTH the registry and the XML. The regex +# matches the UNC host with an optional domain suffix and is idempotent (an +# already-correct FQDN maps to itself). Disabled by -NoDefaultRewrite. +$PartGroupHostShort = 'tsgwp00525' +$PartGroupHostFqdn = 'tsgwp00525.wjs.geaerospace.net' +# \\HOST or \\HOST.any.domain (followed by the next path backslash) -> \\FQDN +$PartGroupHostRx = '(?i)\\\\' + [regex]::Escape($PartGroupHostShort) + '(?:\.[A-Za-z0-9.\-]+)?(?=\\)' +$PartGroupHostTo = '\\' + $PartGroupHostFqdn $ErrorActionPreference = 'Continue' $ts = Get-Date -Format 'yyyyMMdd-HHmmss' $logDir = 'C:\Logs\CMM' @@ -148,6 +167,45 @@ if ($rewrites.Count -gt 0) { } } +# --- Canonicalize the part-group share host to the FQDN in reg + XML --- +# Runs AFTER the geaofi robocopy (so ApplicationSettings.xml is in place) +# and AFTER the literal rewrites above. Idempotent. This is what makes +# goCMM show the FQDN regardless of which form the bay was captured with. +if (-not $NoDefaultRewrite) { + # registry: every string value under the goCMM key + if (Test-Path $goCmmKey) { + try { + $props = Get-ItemProperty -Path $goCmmKey + foreach ($p in $props.PSObject.Properties) { + if ($p.Name -like 'PS*') { continue } + if (($p.Value -is [string]) -and ([regex]::IsMatch($p.Value, $PartGroupHostRx))) { + $new = [regex]::Replace($p.Value, $PartGroupHostRx, $PartGroupHostTo) + if ($new -ne $p.Value) { + Set-ItemProperty -Path $goCmmKey -Name $p.Name -Value $new + Log " host-canon reg [$($p.Name)] -> $new" + } + } + } + } catch { Log " WARN: host canonicalize (reg) failed: $($_.Exception.Message)" } + } + # files: every text file under the Shared Data Directory (incl ApplicationSettings.xml) + if (Test-Path $sharedDir) { + $utf8NoBomHost = New-Object System.Text.UTF8Encoding($false) + Get-ChildItem -Path $sharedDir -Recurse -File -Include *.xml,*.txt,*.csv,*.config,*.ini,*.bas -ErrorAction SilentlyContinue | ForEach-Object { + try { + $c = [System.IO.File]::ReadAllText($_.FullName) + if ([regex]::IsMatch($c, $PartGroupHostRx)) { + $nc = [regex]::Replace($c, $PartGroupHostRx, $PartGroupHostTo) + if ($nc -ne $c) { + [System.IO.File]::WriteAllText($_.FullName, $nc, $utf8NoBomHost) + Log " host-canon file [$($_.Name)] rewritten" + } + } + } catch { Log " WARN: host canonicalize $($_.FullName): $($_.Exception.Message)" } + } + } +} + # --- Grant BUILTIN\Users ReadKey+WriteKey on the reg key (goCMM opens it writable:true to read) --- if (Test-Path $goCmmKey) { try { diff --git a/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.bat b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.bat new file mode 100644 index 0000000..d69bb00 --- /dev/null +++ b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.bat @@ -0,0 +1,41 @@ +@echo off +REM ========================================================================== +REM Update-CMMEnforcer.bat - update ONE CMM's local GE-Enforce lib to the +REM version that honors the PC-DMIS _CmmVersion gate, then self-heal. +REM +REM Usage (run on the CMM PC): +REM Update-CMMEnforcer.bat update lib + report wrong versions + self-heal +REM Update-CMMEnforcer.bat /remove also uninstall the wrong PC-DMIS version(s) +REM +REM Pulls the lib from the tsgwp00525 share by default. To use a USB copy: +REM Update-CMMEnforcer.bat /src "D:\Install-FromManifest.ps1" +REM Update-CMMEnforcer.bat /remove /src "D:\Install-FromManifest.ps1" +REM ========================================================================== +setlocal EnableDelayedExpansion + +REM --- self-elevate to administrator --- +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo Requesting administrator elevation... + powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'" + exit /b +) + +set "PS=%~dp0Update-CMMEnforcer.ps1" +set "ARGS=" + +:parse +if "%~1"=="" goto run +if /I "%~1"=="/remove" set "ARGS=!ARGS! -RemoveWrongVersions" & shift & goto parse +if /I "%~1"=="/src" set "ARGS=!ARGS! -SourceLib '%~2'" & shift & shift & goto parse +shift +goto parse + +:run +echo Running: powershell -File "%PS%" !ARGS! +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" !ARGS! +set "RC=%errorlevel%" +echo. +echo Exit code: %RC% +pause +endlocal diff --git a/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.ps1 b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.ps1 new file mode 100644 index 0000000..22c87b5 --- /dev/null +++ b/playbook/shopfloor-setup/gea-shopfloor-cmm/scripts/Update-CMMEnforcer.ps1 @@ -0,0 +1,131 @@ +<# +Update-CMMEnforcer.ps1 + +Bring ONE CMM shopfloor PC's local GE-Enforce engine up to the lib version +that understands the PC-DMIS _CmmVersion gate (lib MINOR >= 6), then run a +single enforce cycle so it self-heals to this bay's correct PC-DMIS version. + +Why this exists: GE-Enforce runs its lib from LOCAL +C:\Program Files\GE\Shopfloor\lib\ and does NOT auto-update from the share. +A bay running an older lib ignores the _CmmVersion tags in the synced CMM +manifest and would install every version it does not detect. This script +pushes the new lib locally so the gate takes effect. + +Run as administrator on the CMM PC. + +Params: + -SourceLib Explicit path to the 2.6+ lib (e.g. a USB copy). If omitted, + pulls from the tsgwp00525 share (-ShareRoot). + -ShareRoot Share root holding common\lib\Install-FromManifest.ps1. + -RemoveWrongVersions Uninstall any installed PC-DMIS version that does not + match this bay's C:\Enrollment\cmm\version.txt. + -NoEnforceRun Skip the post-update enforce cycle (just update + report). +#> +param( + [string]$SourceLib, + [string]$ShareRoot = '\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor', + [string]$InstallRoot = 'C:\Program Files\GE\Shopfloor', + [int]$MinLibMinor = 6, + [switch]$RemoveWrongVersions, + [switch]$NoEnforceRun +) +$ErrorActionPreference = 'Stop' +function Log($m){ Write-Host ("[Update-CMMEnforcer] {0}" -f $m) } + +function Get-LibMinor($path){ + if (-not (Test-Path -LiteralPath $path)) { return -1 } + $m = Select-String -Path $path -Pattern 'LIB_MANIFEST_MINOR\s*=\s*(\d+)' -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($m) { return [int]$m.Matches[0].Groups[1].Value } else { return -1 } +} + +# --------------------------------------------------------------------------- +# 1. Resolve the source lib (USB override or share) and validate its version +# --------------------------------------------------------------------------- +if (-not $SourceLib) { $SourceLib = Join-Path $ShareRoot 'common\lib\Install-FromManifest.ps1' } +if (-not (Test-Path -LiteralPath $SourceLib)) { + throw "Source lib not found: $SourceLib (if using the share, confirm you are authenticated to it; or pass -SourceLib )" +} +$srcMinor = Get-LibMinor $SourceLib +if ($srcMinor -lt $MinLibMinor) { + throw "Source lib is MINOR=$srcMinor but >= $MinLibMinor is required. Point -SourceLib at the updated lib (or sync the share first)." +} + +$dstLib = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1' +$oldMinor = Get-LibMinor $dstLib +Log "Source lib MINOR=$srcMinor ($SourceLib)" +Log "Local lib MINOR=$oldMinor ($dstLib)" + +# --------------------------------------------------------------------------- +# 2. Back up + copy the lib into place, verify +# --------------------------------------------------------------------------- +$libDir = Split-Path -Parent $dstLib +New-Item -ItemType Directory -Path $libDir -Force | Out-Null +if (Test-Path -LiteralPath $dstLib) { + $bak = "$dstLib.bak-{0}" -f (Get-Date -Format 'yyyyMMdd-HHmmss') + Copy-Item -LiteralPath $dstLib -Destination $bak -Force + Log "Backed up local lib -> $bak" +} +Copy-Item -LiteralPath $SourceLib -Destination $dstLib -Force +$newMinor = Get-LibMinor $dstLib +if ($newMinor -lt $MinLibMinor) { throw "Copy did not take - local lib still MINOR=$newMinor" } +Log "Local lib updated: MINOR $oldMinor -> $newMinor (OK - gate is now active)" + +# --------------------------------------------------------------------------- +# 3. Report bay version + which PC-DMIS versions are installed +# --------------------------------------------------------------------------- +$verFile = 'C:\Enrollment\cmm\version.txt' +$bayVer = if (Test-Path -LiteralPath $verFile) { (Get-Content -LiteralPath $verFile -First 1).Trim() } else { '' } +Log "Bay PC-DMIS version (version.txt): $(if($bayVer){$bayVer}else{'(none - bay not resolved; gate will install-all)'})" + +$pcd = @( + [pscustomobject]@{ Ver='2016'; Guid='{5389B196-81F0-44A9-A073-4C1D72041F09}'; Name='PC-DMIS 2016.0' }, + [pscustomobject]@{ Ver='2019'; Guid='{49DBE7F9-228A-4E66-8BB5-DB5A446DCAE7}'; Name='PC-DMIS 2019 R2' }, + [pscustomobject]@{ Ver='2026'; Guid='{81BACE1B-FB08-4DCF-8100-79911AD3EC1E}'; Name='PC-DMIS 2026.1' } +) +function Test-MsiInstalled($guid){ + foreach($r in @( + "HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\$guid", + "HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\$guid")){ + if (Test-Path -LiteralPath $r) { return $true } + } + return $false +} +$installed = @($pcd | Where-Object { Test-MsiInstalled $_.Guid } | ForEach-Object { $_.Ver }) +Log "PC-DMIS installed: $(if($installed){$installed -join ', '}else{'none'})" +$wrong = @($installed | Where-Object { $bayVer -and $_ -ne $bayVer }) +if ($wrong.Count) { Log "WRONG for this bay (expected $bayVer): $($wrong -join ', ')" } + +# --------------------------------------------------------------------------- +# 4. Optionally uninstall wrong versions +# --------------------------------------------------------------------------- +if ($RemoveWrongVersions -and $bayVer -and $wrong.Count) { + foreach($p in ($pcd | Where-Object { $wrong -contains $_.Ver })){ + Log "Uninstalling $($p.Name) $($p.Guid) ..." + $proc = Start-Process msiexec.exe -ArgumentList "/x $($p.Guid) /qn /norestart REBOOT=ReallySuppress" -Wait -PassThru -WindowStyle Hidden + Log " msiexec exit $($proc.ExitCode) $(if($proc.ExitCode -in 0,1605,3010){'(OK)'}else{'(check)'})" + } +} elseif ($wrong.Count) { + Log "Re-run with -RemoveWrongVersions to uninstall the wrong version(s)." +} + +# --------------------------------------------------------------------------- +# 5. Kick one enforce cycle so it self-heals against the gated manifest +# --------------------------------------------------------------------------- +if (-not $NoEnforceRun) { + $task = Get-ScheduledTask -ErrorAction SilentlyContinue | Where-Object { $_.TaskName -match 'Enforce' } | Select-Object -First 1 + $enf = Join-Path $InstallRoot 'GE-Enforce.ps1' + if ($task) { + Log "Triggering scheduled task '$($task.TaskName)' ..." + Start-ScheduledTask -TaskName $task.TaskName -TaskPath $task.TaskPath + Log "Triggered. Watch C:\Logs\Shopfloor\enforce-*.log" + } elseif (Test-Path -LiteralPath $enf) { + Log "No enforce task found - running GE-Enforce.ps1 directly ..." + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File $enf | Out-Null + Log "Enforce cycle done. See C:\Logs\Shopfloor\enforce-*.log" + } else { + Log "Could not find an enforce task or $enf - trigger enforcement manually." + } +} + +Log "DONE. lib MINOR=$newMinor | bay=$(if($bayVer){$bayVer}else{'?'}) | installed=$($installed -join ',')" +Log "Verify next enforce log shows the 2016/2026 entries skipped with '_CmmVersion filter'." diff --git a/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.bat b/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.bat new file mode 100644 index 0000000..fe50455 --- /dev/null +++ b/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.bat @@ -0,0 +1,37 @@ +@echo off +REM ========================================================================== +REM Debug-ShopDBReporting.bat - diagnose a PC not updating its ShopDB entry. +REM +REM Usage (run on the problem PC): +REM Debug-ShopDBReporting.bat full triage incl a live test POST +REM Debug-ShopDBReporting.bat /nopost inspect log + registry only (no POST) +REM Debug-ShopDBReporting.bat /mn 2001 force the machine number sent +REM +REM The live POST writes this PC's row to ShopDB (idempotent upsert, same as the +REM scheduled reporter). Use /nopost to avoid writing. +REM ========================================================================== +setlocal EnableDelayedExpansion + +REM --- self-elevate --- +net session >nul 2>&1 +if %errorlevel% neq 0 ( + echo Requesting administrator elevation... + powershell -NoProfile -Command "Start-Process -Verb RunAs -FilePath '%~f0' -ArgumentList '%*'" + exit /b +) + +set "PS=%~dp0Debug-ShopDBReporting.ps1" +set "ARGS=" + +:parse +if "%~1"=="" goto run +if /I "%~1"=="/nopost" set "ARGS=!ARGS! -NoPost" & shift & goto parse +if /I "%~1"=="/mn" set "ARGS=!ARGS! -MachineNo '%~2'" & shift & shift & goto parse +shift +goto parse + +:run +powershell.exe -NoProfile -ExecutionPolicy Bypass -File "%PS%" !ARGS! +echo. +pause +endlocal diff --git a/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.ps1 b/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.ps1 new file mode 100644 index 0000000..6ef665d --- /dev/null +++ b/playbook/shopfloor-setup/gea-shopfloor-collections/Debug-ShopDBReporting.ps1 @@ -0,0 +1,199 @@ +<# +Debug-ShopDBReporting.ps1 + +One-shot diagnosis of why a shopfloor PC is not updating its ShopDB entry through +api.asp (action=updateCompleteAsset). Runs the whole triage and prints a verdict: + + 1. Preconditions - hostname, BIOS serial (api.asp requires it), eDNC MachineNo + (the PC->machine relationship needs it). + 2. Client log - the latest C:\Logs\Shopfloor\report-asset-*.log POST/RESPONSE. + 3. Live test POST - calls api.asp updateCompleteAsset and parses the JSON. + 4. Verdict - likely cause + fix. + +NOTE: the live POST WRITES to ShopDB (it is the real reporter action - an idempotent +upsert of this PC's row, same as the scheduled reporter does). Use -NoPost to skip +it and only inspect the local log + registry. + +Run as administrator on the problem PC. + +Params: + -ApiUrl override the api.asp endpoint + -MachineNo override the machine number sent (default: read from eDNC registry) + -NoPost do not send the live test POST +#> +param( + [string]$ApiUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb/api.asp', + [string]$MachineNo, + [int]$TimeoutSec = 30, + [switch]$NoPost +) + +$ErrorActionPreference = 'Continue' +function Section($t){ Write-Host ''; Write-Host ("==== {0} ====" -f $t) -ForegroundColor Cyan } +function KV($k,$v){ Write-Host (" {0,-20}: {1}" -f $k, $v) } + +Write-Host '########################################################' +Write-Host '# ShopDB Reporting Debug' +Write-Host ("# {0}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss')) +Write-Host '########################################################' + +# --------------------------------------------------------------------------- +# 1. Preconditions +# --------------------------------------------------------------------------- +Section '1. Preconditions' +$hostname = $env:COMPUTERNAME +$serial = '' +try { $serial = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber } catch { + try { $serial = (Get-WmiObject Win32_BIOS -ErrorAction Stop).SerialNumber } catch {} +} +$serial = ("" + $serial).Trim() + +# Resolve machine number the same way the reporter / GE-Enforce lib do: +# eDNC registry (WOW6432Node then native) first, then C:\Enrollment\machine-number.txt. +$regMachineNo = '' +foreach ($rp in @('HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', + 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General')) { + if ($regMachineNo) { break } + if (Test-Path $rp) { + try { $regMachineNo = ("" + (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction Stop).MachineNo).Trim() } catch {} + } +} +$txtMachineNo = '' +$mnFile = 'C:\Enrollment\machine-number.txt' +if (Test-Path -LiteralPath $mnFile) { + try { $txtMachineNo = ("" + (Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop)).Trim() } catch {} +} +$machineNoSource = '' +if (-not $MachineNo) { + if ($regMachineNo) { $MachineNo = $regMachineNo; $machineNoSource = 'eDNC registry' } + elseif ($txtMachineNo) { $MachineNo = $txtMachineNo; $machineNoSource = 'machine-number.txt (fallback)' } +} else { $machineNoSource = 'override param' } + +$corpIp = '' +try { + $corpIp = (Get-NetIPAddress -AddressFamily IPv4 -ErrorAction Stop | + Where-Object { $_.IPAddress -notmatch '^(127\.|169\.254\.)' } | + Select-Object -First 1).IPAddress +} catch {} + +KV 'Hostname' $hostname +KV 'BIOS serial' ($(if ($serial) { $serial } else { '(MISSING - api.asp will reject)' })) +KV 'eDNC MachineNo (reg)' ($(if ($regMachineNo) { $regMachineNo } else { '(none)' })) +KV 'machine-number.txt' ($(if ($txtMachineNo) { $txtMachineNo } else { '(none)' })) +KV 'MachineNo to send' ($(if ($MachineNo) { "$MachineNo [from $machineNoSource]" } else { '(none - relationship will be skipped)' })) +KV 'Corp IPv4' ($(if ($corpIp) { $corpIp } else { '(none found)' })) +KV 'API endpoint' $ApiUrl + +# --------------------------------------------------------------------------- +# 2. Latest client reporter log +# --------------------------------------------------------------------------- +Section '2. Latest client reporter log' +$logDir = 'C:\Logs\Shopfloor' +$log = $null +if (Test-Path $logDir) { + $log = Get-ChildItem -Path $logDir -Filter 'report-asset-*.log' -ErrorAction SilentlyContinue | + Sort-Object LastWriteTime | Select-Object -Last 1 +} +if ($log) { + KV 'Log file' $log.FullName + KV 'Last written' $log.LastWriteTime + Write-Host ' --- last POST / RESPONSE / ERROR lines ---' + Get-Content $log.FullName -ErrorAction SilentlyContinue | + Select-String -Pattern 'POST |RESPONSE |ERROR ' | + Select-Object -Last 6 | ForEach-Object { Write-Host (" " + $_.Line) } +} else { + Write-Host ' No report-asset-*.log found. The reporter may never have run on this PC.' + Write-Host ' -> check the scheduled task / GE-Enforce entry that invokes Report-AssetToShopDB.ps1' +} + +# --------------------------------------------------------------------------- +# 3. Live test POST +# --------------------------------------------------------------------------- +Section '3. Live test POST to api.asp' +$resp = $null; $postErr = $null; $rawText = $null +if ($NoPost) { + Write-Host ' -NoPost set: skipping the live POST.' +} elseif (-not $serial) { + Write-Host ' SKIPPED: no BIOS serial, api.asp requires serialNumber. Fix the hardware/WMI read first.' +} else { + # Accept the internal IIS certificate for this run (PS 5.1-safe). + try { + if (-not ([System.Management.Automation.PSTypeName]'TrustAllCerts').Type) { + Add-Type @" +using System.Net; using System.Security.Cryptography.X509Certificates; +public class TrustAllCerts : ICertificatePolicy { + public bool CheckValidationResult(ServicePoint sp, X509Certificate c, WebRequest r, int p) { return true; } +} +"@ + } + [System.Net.ServicePointManager]::CertificatePolicy = New-Object TrustAllCerts + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.SecurityProtocolType]::Tls12 + } catch {} + + $body = @{ + action = 'updateCompleteAsset' + hostname = $hostname + serialNumber = $serial + pcType = 'Shopfloor' + } + if ($MachineNo) { $body['machineNo'] = $MachineNo } + + Write-Host (" POST host={0} serial={1} machineNo={2}" -f $hostname, $serial, $(if ($MachineNo) { $MachineNo } else { '(none)' })) + try { + $resp = Invoke-RestMethod -Uri $ApiUrl -Method Post -Body $body -TimeoutSec $TimeoutSec -ErrorAction Stop + Write-Host ' --- response JSON ---' + Write-Host (" " + ($resp | ConvertTo-Json -Compress -Depth 5)) + } catch { + $postErr = $_.Exception.Message + Write-Host (" POST FAILED: {0}" -f $postErr) -ForegroundColor Red + try { $rawText = $_.Exception.Response } catch {} + } +} + +# --------------------------------------------------------------------------- +# 4. Verdict +# --------------------------------------------------------------------------- +Section '4. VERDICT' +function Truthy($v){ return ($v -eq $true -or "$v" -eq 'True' -or "$v" -eq '1') } + +if ($NoPost) { + Write-Host ' (live POST skipped) Inspect section 1-2 above.' + if (-not $MachineNo) { Write-Host ' NOTE: no machine number (eDNC registry and machine-number.txt both empty) -> no relationship until set.' -ForegroundColor Yellow } +} +elseif ($postErr) { + Write-Host ' CANNOT REACH api.asp - network, TLS, DNS, or IIS error.' -ForegroundColor Red + Write-Host (" Detail: {0}" -f $postErr) + Write-Host ' -> ping/curl the host, confirm IIS is up, check the cert and the /shopdb/ path.' +} +elseif (-not $serial) { + Write-Host ' BIOS SERIAL MISSING - api.asp rejects the POST (serialNumber required).' -ForegroundColor Red + Write-Host ' -> fix WMI/Win32_BIOS on this PC; it is a hardware/OS read issue, not ShopDB.' +} +elseif (-not (Truthy $resp.success)) { + Write-Host ' SERVER RETURNED success=false - it aborted at a DB step.' -ForegroundColor Red + Write-Host (" Breadcrumb/message: {0}" -f $resp.message) + Write-Host ' -> the LAST "N-OK," token in that message is where it stopped. Match N to api.asp.' +} +elseif (-not (Truthy $resp.relationshipCreated)) { + if (-not $MachineNo) { + Write-Host ' ASSET OK, but NO RELATIONSHIP because this PC has no machine number.' -ForegroundColor Yellow + Write-Host (" machineid={0}. Neither the eDNC registry nor C:\Enrollment\machine-number.txt" -f $resp.machineid) + Write-Host ' has a value, so api.asp skips the relationship by design.' + Write-Host ' -> set the bay machine number (Set-MachineNumber writes the eDNC registry), then' + Write-Host ' re-run. CMM/keyence/wax-trace bays have no DNC number and register asset-only.' + } else { + Write-Host ' ASSET OK, but RELATIONSHIP NOT CREATED even though MachineNo was sent.' -ForegroundColor Red + Write-Host (" machineid={0}, machineNo={1}." -f $resp.machineid, $MachineNo) + Write-Host ' -> this is the server-side silent abort (LogToFile Err-leak). Deploy the api.asp fix' + Write-Host ' (commit a4051e3) to prod IIS, then re-run. Also confirm the IIS logs dir is' + Write-Host ' writable by the app-pool identity (that is the trigger).' + } +} +else { + Write-Host ' HEALTHY.' -ForegroundColor Green + Write-Host (" Asset updated (machineid={0}) and PC->machine relationship created." -f $resp.machineid) + Write-Host ' If ShopDB still looks wrong, the issue is data/display, not the reporter path.' +} + +Write-Host '' +Write-Host 'Done.' diff --git a/playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1 b/playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1 index e73bed1..3fad122 100644 --- a/playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1 +++ b/playbook/shopfloor-setup/gea-shopfloor-collections/Report-AssetToShopDB.ps1 @@ -83,16 +83,38 @@ if (-not $serialNumber) { exit 0 } -# DNC machine number from eDNC reg (2001, 2002, ...). optional - sent only if present. +# DNC machine number (2001, 2002, ...). optional - sent only if found. +# Resolution order mirrors the GE-Enforce lib Get-CurrentMachineNumber so the +# reporter and manifest gating agree: +# 1. eDNC registry (WOW6432Node, then native) - follows bay reassignment, which +# Set-MachineNumber rewrites here. +# 2. C:\Enrollment\machine-number.txt - the imaging-time value written once by +# startnet.cmd. Used when eDNC has not populated the registry yet (fresh +# image, or a bay where eDNC has not run), so the PC still reports its number +# and api.asp can build the relationship. $machineNo = '' -$edncRegPath = 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General' -try { - if (Test-Path $edncRegPath) { - $machineNo = [string](Get-ItemProperty -Path $edncRegPath -Name MachineNo -ErrorAction Stop).MachineNo - $machineNo = $machineNo.Trim() +foreach ($regPath in @( + 'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General', + 'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General' +)) { + if ($machineNo) { break } + try { + if (Test-Path $regPath) { + $v = [string](Get-ItemProperty -Path $regPath -Name MachineNo -ErrorAction Stop).MachineNo + if ($v) { $machineNo = $v.Trim() } + } + } catch { + Log "WARN could not read MachineNo from ${regPath}: $($_.Exception.Message)" + } +} +if (-not $machineNo) { + $mnFile = 'C:\Enrollment\machine-number.txt' + if (Test-Path -LiteralPath $mnFile) { + try { + $v = Get-Content -LiteralPath $mnFile -First 1 -ErrorAction Stop + if ($v) { $machineNo = ([string]$v).Trim(); Log "machineNo from $mnFile (eDNC registry empty): $machineNo" } + } catch { Log "WARN could not read ${mnFile}: $($_.Exception.Message)" } } -} catch { - Log "WARN could not read eDNC MachineNo: $($_.Exception.Message)" } # OS caption for the operatingsystems lookup.