Preflight: check what PESetup fails on, before it fails on it

TWO SCRIPTS, DIFFERENT AUDIENCES

playbook/scripts/preflight.ps1 runs at the bay, called by startnet once the
media is mapped. It checks the four things that come straight out of PESetup's
own behaviour:

  secure boot   GatherDataSelection fails outright when SecurebootEnabled != 1
  disk >= 120GB MinRequiredSpaceWithoutCompression is 128849018880
  driver match  reimplements GetDriverByModel - family filter, untrimmed
                comma-separated substring tokens, first match wins - and checks
                the pack is actually on the media
  media age     media expires 30 days after build; Media.tag's timestamp is the
                local proxy

The driver check is the one that earns it. A miss is only a WARNING to PESetup,
so the bay images with no NIC, DNS fails at first boot, and enrollment cannot
reach the CDN - a symptom three steps removed from the cause. Advisory by
design: it reports and pauses on a blocker, the tech decides. Lives on the
enrollment share so it can be fixed without rebuilding boot.wim.

scripts/preflight.py runs on the server before a build day and aggregates
everything already built - driver catalogue lint, unattend lint, per-PCTYPE
media view verify - plus a new advisory firmware-coverage check that lists
catalogued models with no BIOS models.txt entry. That last one is how the
OptiPlex 7020 family sat uncovered: 127 catalogued models, 58 covered today.

First run: driver catalogues clean, all three unattends clean, firmware coverage
advisory only. PREFLIGHT PASSED.

Verified: both scripts parse clean (PowerShell parser / python), startnet parens
balance, every goto resolves, 915 CRLF lines with no bare LF. Deployed -
boot.wim md5 99fd3132, preflight.ps1 on the share.
This commit is contained in:
cproudlock
2026-08-06 14:22:01 -04:00
parent 8c21282024
commit d2200e8522
3 changed files with 294 additions and 0 deletions

139
playbook/scripts/preflight.ps1 Executable file
View File

@@ -0,0 +1,139 @@
# preflight.ps1 - check the things PESetup fails on, before it fails on them.
#
# Called by startnet.cmd once the media is mapped. Prints a short report a tech
# can read at the bay and exits non-zero if a check is fatal.
#
# WHY EACH CHECK IS HERE - all four come from PESetup's own behaviour
# (docs/PESETUP-INTERNALS.md, decompiled 4.0.0.17, observed on 4.0.0.20):
#
# Secure boot GatherDataSelection fails the step outright when
# SecurebootEnabled != 1. Hard failure, minutes into a build.
# Disk size MinRequiredSpaceWithoutCompression is 128849018880 (120 GB).
# Driver match GetDriverByModel returns null on a miss and PESetup logs a
# WARNING and keeps going. The bay images with no drivers, so no
# NIC, so DNS fails at first boot and enrollment cannot reach the
# CDN. The symptom appears far from the cause - this is the check
# that earns the script.
# Media age The media expires 30 days after build. PESetup shows days-left
# on a screen nobody reads.
#
# Lives on the enrollment share so it can be fixed without rebuilding boot.wim.
[CmdletBinding()]
param(
[string]$MediaDrive = 'Z:',
[int]$MinDiskGB = 120,
[int]$MediaWarnDays = 25
)
$ErrorActionPreference = 'Continue'
$fatal = 0
$warn = 0
function Ok { param($m) Write-Host (" [ OK ] " + $m) }
function Warn { param($m) Write-Host (" [WARN] " + $m); $script:warn++ }
function Fail { param($m) Write-Host (" [FAIL] " + $m); $script:fatal++ }
Write-Host ""
Write-Host "======== Pre-imaging checks ========"
# --- 1. Secure boot ------------------------------------------------------
# Confirm-SecureBootUEFI is not always present in WinPE; read the state the
# firmware exposes instead.
try {
$sb = Get-ItemProperty -Path 'HKLM:\SYSTEM\CurrentControlSet\Control\SecureBoot\State' `
-Name UEFISecureBootEnabled -ErrorAction Stop
if ($sb.UEFISecureBootEnabled -eq 1) {
Ok "Secure boot enabled"
} else {
Fail "Secure boot is OFF. PESetup will fail at GatherData. Enable it in BIOS."
}
} catch {
Warn "Could not read secure boot state - if this is a legacy/CSM boot, PESetup will fail."
}
# --- 2. Disk size --------------------------------------------------------
try {
$disk = Get-CimInstance Win32_DiskDrive -ErrorAction Stop |
Where-Object { $_.MediaType -like '*Fixed*' } |
Sort-Object Index | Select-Object -First 1
if ($disk) {
$gb = [math]::Round($disk.Size / 1GB, 1)
if ($gb -ge $MinDiskGB) {
Ok ("Disk 0 is {0} GB ({1})" -f $gb, $disk.Model)
} else {
Fail ("Disk 0 is only {0} GB; PESetup needs {1} GB. ({2})" -f $gb, $MinDiskGB, $disk.Model)
}
} else {
Fail "No fixed disk found. PESetup has nothing to image."
}
} catch {
Warn "Could not enumerate disks: $_"
}
# --- 3. Driver match for THIS model --------------------------------------
# Reimplements GetDriverByModel: family filter first (it knows only Latitude,
# OptiPlex and Precision), then a substring test of comma-separated tokens,
# first match wins. Tokens are NOT trimmed, matching the C#.
try {
$model = (Get-CimInstance Win32_ComputerSystem -ErrorAction Stop).Model
$catalogue = Join-Path $MediaDrive 'Deploy\Control\HardwareDriver.json'
if (-not (Test-Path $catalogue)) {
Warn "HardwareDriver.json not found at $catalogue - cannot check drivers."
} else {
$entries = Get-Content $catalogue -Raw | ConvertFrom-Json
$family = ''
if ($model.ToUpper().Contains('LATITUDE')) { $family = 'Latitude' }
if ($model.ToUpper().Contains('OPTIPLEX')) { $family = 'Optiplex' }
if ($model.ToUpper().Contains('PRECISION')) { $family = 'Precision' }
$hit = $null
foreach ($e in $entries) {
if ($family -and ($e.family -notlike "*$family*")) { continue }
foreach ($tok in ([string]$e.modelswminame).Split(',')) {
if ($tok -and $model.ToLower().Contains($tok.ToLower())) { $hit = $e; break }
}
if ($hit) { break }
}
if ($hit) {
$zip = Join-Path $MediaDrive (([string]$hit.destinationDir) -replace '\*destinationdir\*\\?','')
$zip = Join-Path $zip $hit.fileName
if (Test-Path $zip) {
Ok ("Driver pack for '{0}': {1}" -f $model, $hit.fileName)
} else {
Fail ("Driver pack for '{0}' is listed but MISSING on the media: {1}" -f $model, $hit.fileName)
}
} else {
Fail ("NO driver pack matches '{0}'. PESetup logs this as a warning only - the bay will image with NO network drivers." -f $model)
}
}
} catch {
Warn "Driver check failed: $_"
}
# --- 4. Media age --------------------------------------------------------
# Approximate: PESetup expires media 30 days after build and Media.tag is
# rewritten when the media is rebuilt, so its timestamp is the best local proxy.
try {
$tag = Join-Path $MediaDrive 'Deploy\Control\Media.tag'
if (Test-Path $tag) {
$age = [int]((Get-Date) - (Get-Item $tag).LastWriteTime).TotalDays
if ($age -ge $MediaWarnDays) {
Warn ("Media is about {0} days old; it expires at 30. Rebuild it soon." -f $age)
} else {
Ok ("Media is about {0} days old" -f $age)
}
}
} catch { }
Write-Host "===================================="
if ($fatal -gt 0) {
Write-Host ""
Write-Host " $fatal BLOCKING problem(s) found. Imaging this bay will not work."
Write-Host ""
exit 1
}
if ($warn -gt 0) { Write-Host " $warn warning(s), no blockers." }
else { Write-Host " All checks passed." }
Write-Host ""
exit 0

View File

@@ -386,6 +386,22 @@ if exist "Y:\scripts\winpe-status-push.ps1" (
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\winpe-status-push.ps1"
)
REM --- Pre-imaging checks ---------------------------------------------------
REM Checks the things PESetup fails on, before it fails on them: secure boot
REM (hard failure at GatherData), disk >= 120 GB, a driver pack matching THIS
REM model, and media age. The driver check is the one that earns its keep - a
REM miss is only a WARNING to PESetup, so the bay images with no NIC and the
REM symptom shows up much later as a DNS failure during enrollment.
REM Advisory: it reports and pauses on a blocker, it does not abort. The tech
REM decides. Lives on the share so it can be fixed without a boot.wim rebuild.
if exist "Y:\scripts\preflight.ps1" (
powershell -NoProfile -ExecutionPolicy Bypass -File "Y:\scripts\preflight.ps1" -MediaDrive Z:
if errorlevel 1 (
echo Press any key to image anyway, or power off the bay to stop.
pause >NUL
)
)
echo Waiting for PESetup.exe to start...
:wait_start
ping -n 3 127.0.0.1 >NUL

139
scripts/preflight.py Executable file
View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
preflight.py - run every server-side check before a build day.
The individual linters each catch one class of silent failure. This runs them
together so "is the PXE server fit to image from?" is one command and one exit
code, instead of four things somebody has to remember.
What it runs:
lint-driver-catalogue.py HardwareDriver.json vs PESetup's real matcher -
missing/case-mismatched packs, empty tokens,
shadowed entries. A driver miss is only a WARNING
to PESetup, so the bay images with no NIC.
lint-unattend.py answer files vs the schema limits. One over-length
value invalidates the whole file for its pass and
stops the bay at a dialog with nothing configured.
build-pctype-media.py resolves the critical paths in every per-PCTYPE
--verify media view; a dangling symlink is a dead view.
firmware coverage models in the driver catalogues that have no entry
in the BIOS models.txt, so their firmware is never
offered. This one is advisory - the manifest is a
curated set and most misses are out-of-fleet.
Usage:
./preflight.py # everything
./preflight.py --quick # skip the media-view verify (the slow one)
Exit code is non-zero if any blocking check fails.
"""
import argparse
import os
import subprocess
import sys
HERE = os.path.dirname(os.path.abspath(__file__))
REPO = os.path.dirname(HERE)
PXE_HOST = "172.16.9.1"
PXE_USER = "pxe"
PXE_PASS = "pxe"
MODELS_TXT = "/srv/samba/winpeapps/_shared/BIOS/models.txt"
IMAGE_BASE = "/srv/samba/winpeapps"
def run(label, argv, blocking=True):
print("=" * 72)
print(label)
print("=" * 72)
try:
r = subprocess.run(argv, cwd=REPO)
rc = r.returncode
except OSError as e:
print(" could not run: %s" % e)
rc = 1
status = "PASS" if rc == 0 else ("FAIL" if blocking else "WARN")
print("-> %s (exit %d)\n" % (status, rc))
return rc if blocking else 0
def ssh(cmd):
return subprocess.run(
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "LogLevel=ERROR", "%s@%s" % (PXE_USER, PXE_HOST), cmd],
capture_output=True, text=True)
def firmware_coverage():
"""Models in the driver catalogues with no BIOS manifest entry.
Advisory only. models.txt is deliberately curated and most uncovered models
are legacy or not in this fleet - the value is spotting a model you DO image
whose firmware silently never updates, which is how the OptiPlex 7020 family
sat uncovered until 2026-08-06."""
print("=" * 72)
print("firmware coverage (advisory)")
print("=" * 72)
r = ssh("cat '%s'" % MODELS_TXT)
if r.returncode != 0:
print(" could not read %s\n-> WARN\n" % MODELS_TXT)
return 0
tokens = [ln.split("|")[0] for ln in r.stdout.splitlines()
if ln.strip() and not ln.startswith("#")]
r = ssh("cat %s/*/Deploy/Control/HardwareDriver.json" % IMAGE_BASE)
if r.returncode != 0:
print(" could not read the driver catalogues\n-> WARN\n")
return 0
import json, re
models = set()
for blob in re.findall(r"\[.*?\n\]", r.stdout, re.S):
try:
for e in json.loads(blob):
for t in str(e.get("modelswminame") or "").split(","):
if t.strip():
models.add(t.strip())
except json.JSONDecodeError:
continue
missing = [m for m in sorted(models)
if not any(tok.lower() in m.lower() for tok in tokens)]
print(" %d catalogued models, %d covered, %d with no firmware entry"
% (len(models), len(models) - len(missing), len(missing)))
if missing:
print(" uncovered (check any of these you actually image):")
for m in missing[:15]:
print(" %s" % m)
if len(missing) > 15:
print(" ... and %d more" % (len(missing) - 15))
print("-> WARN (advisory)\n" if missing else "-> PASS\n")
return 0
def main():
ap = argparse.ArgumentParser(description="Run every pre-build check.")
ap.add_argument("--quick", action="store_true",
help="skip the media-view verify (the slow one)")
args = ap.parse_args()
rc = 0
rc |= run("driver catalogue", [sys.executable, "scripts/lint-driver-catalogue.py", "--quiet"])
rc |= run("unattend answer files", [sys.executable, "scripts/lint-unattend.py", "--quiet"])
if not args.quick:
rc |= run("per-PCTYPE media views", [sys.executable, "scripts/build-pctype-media.py", "--verify"])
rc |= firmware_coverage()
print("=" * 72)
if rc:
print("PREFLIGHT FAILED - fix the blocking findings above before imaging.")
return 1
print("PREFLIGHT PASSED")
return 0
if __name__ == "__main__":
sys.exit(main())