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:
139
scripts/preflight.py
Executable file
139
scripts/preflight.py
Executable 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())
|
||||
Reference in New Issue
Block a user