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<serial> -> F<serial> 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.
This commit is contained in:
cproudlock
2026-08-06 15:24:25 -04:00
parent 68f2ce29e4
commit d185e2b810

View File

@@ -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<serial> -> F<serial> 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$','<redacted>') }
}
}
# --- 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') { "$_ = <redacted, length $($v.ToString().Length)>" }
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 ""