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
playbook/scripts/preflight.ps1
Executable file
139
playbook/scripts/preflight.ps1
Executable 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
|
||||||
@@ -386,6 +386,22 @@ if exist "Y:\scripts\winpe-status-push.ps1" (
|
|||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "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...
|
echo Waiting for PESetup.exe to start...
|
||||||
:wait_start
|
:wait_start
|
||||||
ping -n 3 127.0.0.1 >NUL
|
ping -n 3 127.0.0.1 >NUL
|
||||||
|
|||||||
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