From d185e2b810810dea0c37538cff0ed74afd5c1dd8 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 6 Aug 2026 15:24:25 -0400 Subject: [PATCH] Collect everything needed to debug an imaged bay, in one pass Every imaging investigation so far has meant walking to a bay and hand-copying files, and each time discovering another thing we wished we had grabbed at the same moment. This takes the lot. Read-only; changes nothing. Collects: computer name (active AND pending, which is how you tell whether the package's H -> F rename has landed or is still queued for the next reboot), dsregcmd enrollment state, the package self-checks, installed applications, everything that starts by itself, kiosk shortcut targets, Edge policy, GE-Enforce config, drivers, disk, network, provisioning sessions, and the whole of C:\Logs, Panther and the provisioning data plus the diagnostics evtx. Two things it does deliberately: Autostart is captured across all four surfaces - Run/RunOnce in BOTH registry views, all Startup folders, non-Microsoft scheduled tasks, and auto-start services outside C:\Windows. That combination is what identifies which installer planted a given autostart entry. A 32-bit installer's Run key lands under Wow6432Node where 64-bit tooling never looks, which is exactly how the old Dashboard/Lobby autostart survived an earlier purge. It TESTS the ShopDB GE-Enforce API rather than just reporting config. Config on disk proves nothing - a client can be present, configured, and never once succeed. It reports whether a client exists at all, whether anything is scheduled to run it, the configured base URL, and then actually probes the endpoint. A 401 is a good result: it proves DNS, routing and TLS work and the service answered. Only a timeout or DNS failure means unreachable. No token is sent. Deliberately avoids Win32_Product - querying it triggers an MSI reconfigure of every installed product, which is slow and can change the machine. Run it BEFORE lockdown. What it captures is known-CURRENT, not known-good: a bay straight off the line carries applications that should not be there, because preinstall.json entries without a PCTypes filter install everywhere. The point is to have an exact record of what imaging really produces so the unnecessary items can be identified and filtered. After lockdown you cannot tell whether something is absent because lockdown removed it or because imaging never installed it. Staged on the enrollment share alongside the other shopfloor-setup scripts. --- .../Collect-ImagingDiagnostics.ps1 | 371 ++++++++++++++++++ 1 file changed, 371 insertions(+) create mode 100644 playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 diff --git a/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 b/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 new file mode 100644 index 0000000..f95a55b --- /dev/null +++ b/playbook/shopfloor-setup/Collect-ImagingDiagnostics.ps1 @@ -0,0 +1,371 @@ +<# +.SYNOPSIS + Collect everything needed to diagnose an imaged bay, into one zip. + +.DESCRIPTION + Read-only. Changes nothing on the machine. + + Written because every imaging investigation so far has meant walking to a bay + and hand-copying files, and each time we discovered another thing we wished + we had grabbed at the same moment. This takes the lot in one pass. + + Run it on a freshly imaged bay BEFORE lockdown. + + This captures KNOWN-CURRENT, not known-good. A bay straight off the line has + applications on it that should not be there - preinstall.json entries without + a PCTypes filter install everywhere, so a Display bay picks up Adobe, + OpenText and Defect Tracker. The point of collecting is to have an exact + record of what imaging really produces, so the unnecessary items can be + identified and filtered out. + + Before lockdown specifically, because afterwards you cannot tell whether + something is absent because lockdown removed it or because imaging never + installed it. + +.PARAMETER Destination + Where to drop the zip. Defaults to C:\Logs. Point it at a share to collect + centrally, e.g. -Destination \\172.16.9.1\enrollment\imaging-logs + (only reachable while the bay is still on the imaging LAN). + +.PARAMETER Label + Optional tag folded into the filename, e.g. -Label pre-lockdown. + +.EXAMPLE + .\Collect-ImagingDiagnostics.ps1 -Label pre-lockdown + +.EXAMPLE + .\Collect-ImagingDiagnostics.ps1 -Destination \\172.16.9.1\enrollment\imaging-logs +#> + +[CmdletBinding()] +param( + [string]$Destination = 'C:\Logs', + [string]$Label = '' +) + +$ErrorActionPreference = 'Continue' +$ProgressPreference = 'SilentlyContinue' + +$serial = try { (Get-CimInstance Win32_BIOS).SerialNumber.Trim() } catch { 'unknown' } +$stamp = Get-Date -Format 'yyyyMMdd-HHmmss' +$name = if ($Label) { "$serial-$Label-$stamp" } else { "$serial-$stamp" } +$work = Join-Path $env:TEMP "imgdiag-$name" +New-Item -ItemType Directory -Path $work -Force | Out-Null + +function Section { param([string]$File, [scriptblock]$Body) + $p = Join-Path $work $File + try { & $Body 2>&1 | Out-File -FilePath $p -Encoding utf8 -Width 500 } + catch { "COLLECTION ERROR: $_" | Out-File -FilePath $p -Encoding utf8 } + Write-Host (" {0}" -f $File) +} + +function CopyTree { param([string]$Src, [string]$Dst) + if (Test-Path $Src) { + $d = Join-Path $work $Dst + New-Item -ItemType Directory -Path $d -Force | Out-Null + robocopy $Src $d /E /R:0 /W:0 /NFL /NDL /NJH /NJS | Out-Null + Write-Host (" {0}\ <- {1}" -f $Dst, $Src) + } +} + +Write-Host "" +Write-Host "Collecting imaging diagnostics for $serial ..." +Write-Host "" + +# --- 1. Identity --------------------------------------------------------- +# ActiveComputerName is the name in use; ComputerName is the PENDING one. They +# differ when a rename is queued for the next reboot - which is exactly how the +# H -> F transition works, so capturing both tells you whether +# the package's rename landed or is still waiting. +Section 'identity.txt' { + '== computer names ==' + 'Active (in use): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ActiveComputerName' -EA SilentlyContinue).ComputerName + 'Pending (next boot): ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Control\ComputerName\ComputerName' -EA SilentlyContinue).ComputerName + 'NV Hostname: ' + (Get-ItemProperty 'HKLM:\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters' -EA SilentlyContinue).'NV Hostname' + '' + '== hardware ==' + Get-CimInstance Win32_ComputerSystem | Select-Object Manufacturer, Model, Domain, PartOfDomain, TotalPhysicalMemory | Format-List + Get-CimInstance Win32_BIOS | Select-Object SerialNumber, SMBIOSBIOSVersion, ReleaseDate | Format-List + Get-CimInstance Win32_OperatingSystem | Select-Object Caption, Version, BuildNumber, InstallDate, LastBootUpTime | Format-List + '' + '== uptime / install ==' + 'Windows installed: ' + (Get-CimInstance Win32_OperatingSystem).InstallDate +} + +# --- 2. Enrollment state ------------------------------------------------- +# dsregcmd is the authority on whether the Entra join actually happened. +# criticalChecks.json is the package's own self-assessment and the two can +# disagree - "Entra ID Joined: false" right after imaging is normal because the +# bay has not reached the production network yet. +Section 'enrollment.txt' { + '== dsregcmd /status ==' + & dsregcmd /status + '' + '== package self-checks ==' + foreach ($f in 'C:\Logs\BPRT\criticalChecks.json','C:\Logs\BPRT\packageInfo.json', + 'C:\Logs\BPRT\Orchestrator\TokenMatch.json') { + if (Test-Path $f) { "--- $f ---"; Get-Content $f -Raw } + } + '' + '== staged selections ==' + foreach ($f in 'C:\Enrollment\pc-type.txt','C:\Enrollment\display-type.txt', + 'C:\Enrollment\pc-config.txt','C:\Enrollment\machine-number.txt', + 'C:\Enrollment\fetch-source.txt') { + if (Test-Path $f) { "$f = " + ((Get-Content $f -First 1) -replace 'pxe$','') } + } +} + +# --- 3. Installed applications ------------------------------------------ +# Registry Uninstall keys, both views. Deliberately NOT Win32_Product: querying +# it triggers an MSI reconfigure of every installed product, which is slow and +# can actually change the machine. +Section 'installed-apps.txt' { + $paths = @( + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\*', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' + ) + Get-ItemProperty $paths -EA SilentlyContinue | + Where-Object { $_.DisplayName } | + Sort-Object DisplayName | + Select-Object DisplayName, DisplayVersion, Publisher, InstallDate, InstallLocation | + Format-Table -AutoSize +} + +# --- 4. Everything that starts by itself -------------------------------- +# THE section for "why did this app launch". Installers plant their own startup +# entries, so an app being installed IS an app being started - there is no +# separate switch. Capturing all four surfaces shows which app planted what. +# Note both registry views: a 32-bit installer's Run key lands under +# Wow6432Node and 64-bit tooling never sees it. +Section 'autostart.txt' { + '== HKLM Run / RunOnce (native + WOW64) ==' + foreach ($k in 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', + 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Run', + 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\RunOnce') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } + '' + '== HKCU Run / RunOnce (current user) ==' + foreach ($k in 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Run', + 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } + '' + '== Startup folders ==' + foreach ($d in "$env:ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp", + "$env:AppData\Microsoft\Windows\Start Menu\Programs\StartUp") { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name, Length, LastWriteTime | Format-Table -AutoSize } + } + '' + '== per-user Startup folders (all profiles) ==' + Get-ChildItem 'C:\Users' -Directory -EA SilentlyContinue | ForEach-Object { + $d = Join-Path $_.FullName 'AppData\Roaming\Microsoft\Windows\Start Menu\Programs\Startup' + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d | Select-Object Name | Format-Table -AutoSize } + } + '' + '== scheduled tasks (non-Microsoft) ==' + Get-ScheduledTask -EA SilentlyContinue | + Where-Object { $_.TaskPath -notlike '\Microsoft\*' } | + Select-Object TaskPath, TaskName, State, + @{n='Triggers';e={ ($_.Triggers | ForEach-Object { $_.CimClass.CimClassName }) -join ',' }}, + @{n='Action';e={ ($_.Actions | ForEach-Object { $_.Execute }) -join ',' }} | + Sort-Object TaskPath, TaskName | Format-Table -AutoSize -Wrap + '' + '== services set to auto-start (non-Microsoft paths) ==' + Get-CimInstance Win32_Service -EA SilentlyContinue | + Where-Object { $_.StartMode -eq 'Auto' -and $_.PathName -notmatch 'C:\\Windows\\' } | + Select-Object Name, DisplayName, State, StartMode, PathName | + Sort-Object Name | Format-Table -AutoSize -Wrap +} + +# --- 5. Kiosk / display specifics --------------------------------------- +Section 'display-kiosk.txt' { + '== kiosk shortcuts anywhere in Startup ==' + Get-ChildItem 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp' -EA SilentlyContinue | + ForEach-Object { + $sh = (New-Object -ComObject WScript.Shell).CreateShortcut($_.FullName) + "{0}`n target: {1}`n args: {2}" -f $_.Name, $sh.TargetPath, $sh.Arguments + } + '' + '== Edge policies ==' + foreach ($k in 'HKLM:\SOFTWARE\Policies\Microsoft\Edge', + 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\System') { + if (Test-Path $k) { "--- $k ---"; Get-ItemProperty $k | Format-List } + } +} + +# --- 6. GE-Enforce ------------------------------------------------------- +Section 'ge-enforce.txt' { + '== registry config ==' + foreach ($k in 'HKLM:\SOFTWARE\GE\SFLD\Credentials','HKLM:\SOFTWARE\GE\SFLD\DSC', + 'HKLM:\SOFTWARE\GE\ShopDB') { + # values only - do not dump anything that looks like a secret + if (Test-Path $k) { + "--- $k ---" + Get-Item $k | Select-Object -ExpandProperty Property | ForEach-Object { + $v = (Get-ItemProperty $k -Name $_).$_ + if ($_ -match '(?i)key|token|secret|password|sas') { "$_ = " } + else { "$_ = $v" } + } + } + } + '' + '== enforce client / DSC on disk ==' + foreach ($d in 'C:\ProgramData\SFLD','C:\Deploy\Applications\BPRT') { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse -Depth 1 | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } +} + +# --- 6b. IS IT ACTUALLY TALKING TO THE SHOPDB GE-ENFORCE API? ----------- +# Config on disk proves nothing - the client can be present, configured, and +# never once succeed. This answers the actual question: is there a client, is it +# scheduled, has it run, and can this bay reach the endpoint right now. +# +# A 401 from the manifest endpoint is a GOOD result here: it proves DNS, routing +# and TLS all work and the service answered. Only a timeout or DNS failure means +# genuinely unreachable. No token is sent, so nothing here can enrol or change +# anything. +Section 'geenforce-api.txt' { + '== is the client installed? ==' + $clientPaths = @( + 'C:\Program Files\ShopDB', 'C:\ProgramData\ShopDB', + 'C:\Deploy\Applications\BPRT\ShopdbEnforceClient.psm1', + 'C:\Enrollment\ShopdbEnforceClient.psm1' + ) + $found = $false + foreach ($p in $clientPaths) { + if (Test-Path $p) { $found = $true; "FOUND: $p" + Get-ChildItem $p -Recurse -EA SilentlyContinue | + Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } + if (-not $found) { 'NOT FOUND - no ShopDB enforce client on this machine.' } + '' + '== scheduled tasks that would run it ==' + $tasks = Get-ScheduledTask -EA SilentlyContinue | + Where-Object { $_.TaskName -match '(?i)shopdb|ge-?enforce|asset report' } + if ($tasks) { + foreach ($t in $tasks) { + $i = $t | Get-ScheduledTaskInfo -EA SilentlyContinue + "{0}\{1} State={2} LastRun={3} LastResult={4} NextRun={5}" -f ` + $t.TaskPath, $t.TaskName, $t.State, $i.LastRunTime, $i.LastTaskResult, $i.NextRunTime + ($t.Actions | ForEach-Object { " action: $($_.Execute) $($_.Arguments)" }) + } + } else { 'NONE - nothing scheduled to call the API.' } + '' + '== configured base URL ==' + $base = $null + foreach ($k in 'HKLM:\SOFTWARE\GE\ShopDB','HKLM:\SOFTWARE\GE\SFLD\Credentials') { + if (Test-Path $k) { + $p = Get-ItemProperty $k + foreach ($n in $p.PSObject.Properties.Name) { + if ($n -match '(?i)url|base|endpoint') { "$k\$n = $($p.$n)"; if (-not $base) { $base = $p.$n } } + } + } + } + if (-not $base) { '(no BaseUrl configured - falling back to the known prod URL for the reachability test)' } + if (-not $base) { $base = 'https://tsgwp00525.wjs.geaerospace.net/shopdb' } + '' + "== can this bay reach it right now? (base: $base) ==" + $targets = @( + @{ Name = 'API docs'; Url = "$base/api/docs" }, + @{ Name = 'GE-Enforce manifest'; Url = "$base/api/geenforce/manifest?pctype=gea-shopfloor-display" } + ) + foreach ($t in $targets) { + try { + $sw = [Diagnostics.Stopwatch]::StartNew() + $r = Invoke-WebRequest -Uri $t.Url -UseBasicParsing -TimeoutSec 20 -EA Stop + $sw.Stop() + "{0,-22} HTTP {1} in {2} ms <- reachable" -f $t.Name, $r.StatusCode, $sw.ElapsedMilliseconds + } catch { + $code = try { $_.Exception.Response.StatusCode.value__ } catch { $null } + if ($code) { + "{0,-22} HTTP {1} <- REACHABLE (service answered; 401/403 just means no token was sent)" -f $t.Name, $code + } else { + "{0,-22} UNREACHABLE: {1}" -f $t.Name, $_.Exception.Message + } + } + } + '' + '== name resolution / route ==' + try { + $h = ([uri]$base).Host + "host: $h" + Resolve-DnsName $h -EA Stop | Select-Object Name, Type, IPAddress | Format-Table -AutoSize + Test-NetConnection -ComputerName $h -Port 443 -InformationLevel Detailed -WarningAction SilentlyContinue | + Select-Object ComputerName, RemoteAddress, TcpTestSucceeded, PingSucceeded | Format-List + } catch { "DNS/route check failed: $_" } + '' + '== client logs, if any ==' + foreach ($d in 'C:\Logs\ShopDB','C:\Logs\GE-Enforce','C:\ProgramData\ShopDB\Logs') { + if (Test-Path $d) { "--- $d ---"; Get-ChildItem $d -Recurse | Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize } + } +} + +# --- 7. Drivers, disk, network ------------------------------------------ +Section 'drivers-disk-network.txt' { + '== third-party drivers ==' + & pnputil /enum-drivers + '' + '== disk layout ==' + Get-Disk -EA SilentlyContinue | Format-Table -AutoSize + Get-Partition -EA SilentlyContinue | Format-Table -AutoSize + Get-Volume -EA SilentlyContinue | Format-Table -AutoSize + '' + '== network ==' + Get-NetAdapter -EA SilentlyContinue | Select-Object Name, InterfaceDescription, Status, LinkSpeed, MacAddress | Format-Table -AutoSize + Get-NetIPAddress -AddressFamily IPv4 -EA SilentlyContinue | Select-Object InterfaceAlias, IPAddress, PrefixOrigin | Format-Table -AutoSize + Get-DnsClientServerAddress -AddressFamily IPv4 -EA SilentlyContinue | Format-Table -AutoSize +} + +# --- 8. Provisioning session state -------------------------------------- +Section 'provisioning-sessions.txt' { + '== sessions ==' + $k = 'HKLM:\SOFTWARE\Microsoft\Provisioning\Sessions' + if (Test-Path $k) { + Get-ChildItem $k | ForEach-Object { + $p = Get-ItemProperty $_.PSPath + "{0} State={1} RebootCount={2}" -f $_.PSChildName, $p.State, $p.RebootCount + } + } else { '(no provisioning sessions key)' } +} + +# --- 9. Log trees -------------------------------------------------------- +CopyTree 'C:\Logs' 'Logs' +CopyTree 'C:\Windows\Panther' 'Panther' +CopyTree 'C:\ProgramData\Microsoft\Provisioning' 'ProvisioningData' +foreach ($f in 'C:\Enrollment\winpe-staging.log','C:\Enrollment\setupcomplete.log') { + if (Test-Path $f) { Copy-Item $f $work -Force -EA SilentlyContinue } +} + +# Enrollment dir listing only - it holds an 8 GB package we do not want. +Section 'enrollment-dir-listing.txt' { + if (Test-Path 'C:\Enrollment') { + Get-ChildItem 'C:\Enrollment' -Recurse -EA SilentlyContinue | + Select-Object FullName, Length, LastWriteTime | Format-Table -AutoSize + } +} + +# --- 10. Provisioning event log ----------------------------------------- +try { + $evtx = Join-Path $work 'Provisioning-Diagnostics-Admin.evtx' + & wevtutil epl 'Microsoft-Windows-Provisioning-Diagnostics-Provider/Admin' $evtx /ow:true 2>$null + if (Test-Path $evtx) { Write-Host ' Provisioning-Diagnostics-Admin.evtx' } +} catch { } + +# --- zip ----------------------------------------------------------------- +New-Item -ItemType Directory -Path $Destination -Force -EA SilentlyContinue | Out-Null +$zip = Join-Path $Destination "imgdiag-$name.zip" +try { + Compress-Archive -Path (Join-Path $work '*') -DestinationPath $zip -Force -EA Stop + Remove-Item $work -Recurse -Force -EA SilentlyContinue + Write-Host "" + Write-Host "Wrote $zip" + Write-Host ("Size: {0:N1} MB" -f ((Get-Item $zip).Length / 1MB)) +} catch { + Write-Host "" + Write-Host "Could not zip to $Destination : $_" + Write-Host "Raw collection left at: $work" +} +Write-Host ""