From d2200e8522c6bb7dc6c0843238499e3f20e7e0e8 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 6 Aug 2026 14:22:01 -0400 Subject: [PATCH] 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. --- playbook/scripts/preflight.ps1 | 139 +++++++++++++++++++++++++++++++++ playbook/startnet.cmd | 16 ++++ scripts/preflight.py | 139 +++++++++++++++++++++++++++++++++ 3 files changed, 294 insertions(+) create mode 100755 playbook/scripts/preflight.ps1 create mode 100755 scripts/preflight.py diff --git a/playbook/scripts/preflight.ps1 b/playbook/scripts/preflight.ps1 new file mode 100755 index 0000000..a6ab3b3 --- /dev/null +++ b/playbook/scripts/preflight.ps1 @@ -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 diff --git a/playbook/startnet.cmd b/playbook/startnet.cmd index a66b07b..fde408d 100644 --- a/playbook/startnet.cmd +++ b/playbook/startnet.cmd @@ -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 diff --git a/scripts/preflight.py b/scripts/preflight.py new file mode 100755 index 0000000..41aa298 --- /dev/null +++ b/scripts/preflight.py @@ -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())