Files
pxe-server/scripts/lint-driver-catalogue.py
cproudlock dfa026b4ee Lint the driver catalogue against PESetup's real matcher
A driver miss is only a warning: GetDriverByModel returns null, PESetup logs
"driver for [MODEL] not found" and images the machine anyway. The bay comes up
with no NIC and no WiFi, DNS fails at first boot, and bulk enrollment cannot
reach the CDN - symptoms far enough from the cause that the OptiPlex Micro 7020
pack sat missing and the Display MicroPC failures were blamed on a drive letter.

Reimplements the matcher from the decompiled source (docs/PESETUP-INTERNALS.md)
and reports what silently breaks it:

  virtual-platform  one such entry flips the tool into virtual-only mode and
                    hard-fails every physical machine
  empty-token       a trailing comma yields "", and Contains("") is true for
                    every model, so that entry swallows the catalogue
  token-whitespace  Split(',') does not trim, so " OptiPlex 3010" needs the
                    space present in the model string too
  case-mismatch     the share is case-sensitive; Optiplex vs OptiPlex splits the
                    tree and the pack is never found
  missing-zip       referenced pack absent
  family-mismatch   the family filter runs first, so a token whose line
                    contradicts the family field can never match
  shadowed          first match wins, so a later entry may be unreachable
  duplicate-token   osId is not part of the match, so a win10 pack can land on a
                    win11 build purely by ordering

--models resolves real WMI model strings through the same code, which is the
check that actually predicts a no-driver build. Exits non-zero on ERROR or
CRITICAL so it can gate a deploy.

Verified both ways: a synthetic catalogue carrying each defect reports all eight
and exits 1; the three live catalogues on 172.16.9.1 come back clean at 0. The
file listing needs find -L and the same anchoring as destinationDir - without
either, every zip check silently passes.
2026-08-06 10:46:28 -04:00

436 lines
17 KiB
Python
Executable File

#!/usr/bin/env python3
"""
lint-driver-catalogue.py - Validate HardwareDriver.json against PESetup's real matcher.
PESetup selects a driver pack with GetDriverByModel (see docs/PESETUP-INTERNALS.md)
and a miss is only a WARNING: imaging finishes, the machine comes up with no NIC and
no WiFi, DNS fails at first boot, and bulk enrollment cannot reach the CDN. The
symptom appears far from the cause, which is how the OptiPlex Micro 7020 driver pack
went unnoticed and how the Display MicroPC failures were misattributed to a drive
letter.
This script reimplements the matcher exactly and reports the ways a catalogue can be
silently wrong:
- a "virtual platform" entry, which fails every physical machine
- an empty token (trailing comma), which matches every model string
- tokens carrying whitespace, which the C# Split(',') never trims
- a referenced zip that is absent, or present under a different case
- an entry that can never win because an earlier entry shadows it
- a family field that contradicts the entry's own tokens
- two entries covering one model for different OS versions (first wins, not the
OS-appropriate one)
Usage:
./lint-driver-catalogue.py # lint every image type on the PXE server
./lint-driver-catalogue.py --image gea-shopfloor # one image type
./lint-driver-catalogue.py --local /srv/samba/winpeapps # run on the server itself
./lint-driver-catalogue.py --models fleet.txt # also resolve real WMI model strings
./lint-driver-catalogue.py --quiet # findings only, no per-entry detail
Exits non-zero when any ERROR or CRITICAL is found, so it can gate a deploy.
Requires: sshpass (remote mode only)
"""
import argparse
import json
import subprocess
import sys
from pathlib import PurePosixPath
PXE_HOST = "172.16.9.1"
PXE_USER = "pxe"
PXE_PASS = "pxe"
IMAGE_BASE = "/srv/samba/winpeapps"
# Where destinationDir points, relative to the image root. A symlink into _shared.
DRIVERS_SUBDIR = "Deploy/Out-of-box Drivers"
# PESetup only knows these three Dell lines. Anything else skips the family filter
# and rides entirely on modelswminame substrings.
KNOWN_FAMILIES = ("Latitude", "Optiplex", "Precision")
CRITICAL, ERROR, WARN, INFO = "CRITICAL", "ERROR", "WARN", "INFO"
FAIL_LEVELS = (CRITICAL, ERROR)
# ---------------------------------------------------------------------------
# Transport
# ---------------------------------------------------------------------------
def ssh_cmd(host, cmd):
return subprocess.run(
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd],
capture_output=True, text=True)
def read_remote(host, path):
r = ssh_cmd(host, "cat '%s'" % path)
if r.returncode != 0:
return None
return r.stdout
def list_remote(host, path):
"""Every file under path, relative and POSIX. Empty list if path is gone.
-L is required: Deploy/Out-of-box Drivers is a symlink into _shared, and plain
find reports the symlink itself and descends nothing, which silently turns every
zip-existence check into a no-op."""
r = ssh_cmd(host, "find -L '%s' -type f -printf '%%P\\n' 2>/dev/null" % path)
if r.returncode != 0:
return []
return [line for line in r.stdout.splitlines() if line]
def read_local(path):
try:
with open(path) as f:
return f.read()
except OSError:
return None
def list_local(path):
# os.walk with followlinks, not rglob: the drivers dir is a symlink into
# _shared and rglob will not descend it.
import os
if not os.path.isdir(path):
return []
out = []
for root, _dirs, names in os.walk(path, followlinks=True):
rel = os.path.relpath(root, path)
for n in names:
out.append(n if rel == "." else os.path.join(rel, n))
return out
# ---------------------------------------------------------------------------
# PESetup's matcher, reimplemented
# ---------------------------------------------------------------------------
def model_family(model):
"""PESetup: three Contains() tests, last one wins, "" when none hit."""
upper = model.upper()
family = ""
if "LATITUDE" in upper:
family = "Latitude"
if "OPTIPLEX" in upper:
family = "Optiplex"
if "PRECISION" in upper:
family = "Precision"
return family
def entry_tokens(entry):
"""modelswminame split on comma. NOT trimmed - the C# does not trim either."""
return str(entry.get("modelswminame") or "").split(",")
def entry_matches(entry, model):
family = model_family(model)
if family and family.lower() not in str(entry.get("family") or "").lower():
return False
for token in entry_tokens(entry):
# C#: model.ToLower().Contains(token.ToLower()). "" is contained by
# every string, so a trailing comma matches all models.
if token.lower() in model.lower():
return True
return False
def select_driver(entries, model):
"""First match wins, exactly like GetDriverByModel. None when nothing matches."""
for i, entry in enumerate(entries):
if entry_matches(entry, model):
return i, entry
return None, None
# ---------------------------------------------------------------------------
# Path resolution
# ---------------------------------------------------------------------------
def resolve_dest_dir(dest):
"""*destinationdir*\\Deploy\\... -> Deploy/... (matches download-drivers.py)."""
return (str(dest or "")
.replace("*destinationdir*\\", "")
.replace("*destinationdir*", "")
.replace("\\", "/")
.strip("/"))
def entry_zip_relpath(entry):
dest = resolve_dest_dir(entry.get("destinationDir") or entry.get("DestinationDir"))
name = str(entry.get("fileName") or entry.get("FileName") or "")
if not name:
return None
return str(PurePosixPath(dest) / name) if dest else name
# ---------------------------------------------------------------------------
# Checks
# ---------------------------------------------------------------------------
class Findings:
def __init__(self):
self.items = []
def add(self, level, check, message, entry_index=None):
self.items.append((level, check, message, entry_index))
def worst(self):
for level in (CRITICAL, ERROR, WARN, INFO):
if any(i[0] == level for i in self.items):
return level
return None
def count(self, level):
return sum(1 for i in self.items if i[0] == level)
def label(entry, index):
name = entry.get("modelsfriendlyname") or entry.get("modelswminame") or "?"
return "[%02d] %s" % (index, name)
def check_catalogue(entries, files, findings):
"""Structural checks: one pass over the entries, no share lookups."""
# A file listing is only meaningful when the drivers tree was readable.
files_lower = {f.lower(): f for f in files}
files_set = set(files)
for i, entry in enumerate(entries):
tag = label(entry, i)
# 1. Virtual-platform hijack. One entry anywhere flips the whole tool
# into virtual-only mode and every physical machine hard-fails.
manufacturer = str(entry.get("manufacturer") or "")
if "virtual platform" in manufacturer.lower():
findings.add(CRITICAL, "virtual-platform",
"%s manufacturer is %r. PESetup takes list[0] as the driver and "
"then REQUIRES the machine to look virtual, so every physical "
"machine fails GatherDataSelection." % (tag, manufacturer), i)
# 2. Empty token. C# Contains("") is true for every string, so this entry
# swallows every model whose family passes the filter.
tokens = entry_tokens(entry)
if any(t == "" for t in tokens):
findings.add(CRITICAL, "empty-token",
"%s modelswminame %r yields an EMPTY token (trailing or doubled "
"comma). An empty token matches every model string, so this entry "
"hijacks the catalogue." % (tag, entry.get("modelswminame")), i)
# 3. Untrimmed whitespace. Split(',') keeps the space, so the model string
# must literally contain " OptiPlex 7020" to match.
for token in tokens:
if token and token != token.strip():
findings.add(ERROR, "token-whitespace",
"%s token %r carries whitespace. PESetup does not trim, so a "
"model must contain the space too - this token will usually "
"miss." % (tag, token), i)
if not str(entry.get("modelswminame") or "").strip():
findings.add(ERROR, "no-tokens",
"%s has an empty modelswminame, so no model can ever select it."
% tag, i)
# 4. Family filter contradicting the entry's own tokens. PESetup derives the
# family from the MODEL, then requires entry.family to contain it.
family_field = str(entry.get("family") or "")
for token in tokens:
token = token.strip()
if not token:
continue
implied = model_family(token)
if implied and implied.lower() not in family_field.lower():
findings.add(ERROR, "family-mismatch",
"%s token %r implies family %s, but family is %r. The filter "
"runs before the token test, so this token can never match."
% (tag, token, implied, family_field), i)
# 5. Referenced zip present, case-exact. The share is a case-sensitive Linux
# filesystem; Windows-side tooling that writes "Optiplex" instead of
# "OptiPlex" splits the tree and the pack is never found.
rel = entry_zip_relpath(entry)
if not rel:
findings.add(ERROR, "no-filename",
"%s has no fileName, so CopyDrivers has nothing to unzip." % tag, i)
elif files:
if rel not in files_set:
alt = files_lower.get(rel.lower())
if alt:
findings.add(ERROR, "case-mismatch",
"%s references %r but the share holds %r. Case-sensitive "
"filesystem: PESetup will not find it." % (tag, rel, alt), i)
else:
findings.add(ERROR, "missing-zip",
"%s references %r, which is not on the share. Driver miss "
"is a WARNING only, so imaging finishes with no drivers."
% (tag, rel), i)
# 6. Shadowing. Replay each entry's own model names through the real matcher;
# if a different entry wins, this one is dead weight or an outright trap.
for i, entry in enumerate(entries):
for token in entry_tokens(entry):
token = token.strip()
if not token:
continue
winner_i, _ = select_driver(entries, token)
if winner_i is not None and winner_i != i:
findings.add(WARN, "shadowed",
"%s token %r is answered by %s instead (first match wins)."
% (label(entry, i), token, label(entries[winner_i], winner_i)), i)
# 7. One model, several OS builds. GetDriverByModel does not look at osId, so
# the win10 pack can land on a win11 build purely by ordering.
by_token = {}
for i, entry in enumerate(entries):
for token in entry_tokens(entry):
token = token.strip().lower()
if token:
by_token.setdefault(token, []).append(i)
for token, idxs in sorted(by_token.items()):
if len(idxs) > 1:
detail = ", ".join("%s osId=%s" % (label(entries[i], i), entries[i].get("osId"))
for i in idxs)
findings.add(WARN, "duplicate-token",
"token %r is claimed by %d entries (%s). osId is NOT part of the "
"match, so the first one wins regardless of the OS being applied."
% (token, len(idxs), detail))
def check_fleet(entries, models, findings):
"""Resolve real WMI model strings. A miss here is a machine with no drivers."""
for model in models:
idx, entry = select_driver(entries, model)
if entry is None:
findings.add(ERROR, "fleet-miss",
"model %r matches NO entry. PESetup logs 'driver for [%s] not "
"found' as a warning and images the machine with no drivers."
% (model, model))
else:
findings.add(INFO, "fleet-match",
"model %r -> %s (%s)" % (model, label(entry, idx),
entry.get("fileName")))
# ---------------------------------------------------------------------------
# Driver
# ---------------------------------------------------------------------------
def lint_image(name, catalogue_text, files, models, quiet):
print("=" * 72)
print("IMAGE: %s" % name)
print("=" * 72)
if catalogue_text is None:
print(" SKIP: no HardwareDriver.json")
return None
try:
entries = json.loads(catalogue_text)
except json.JSONDecodeError as e:
print(" CRITICAL: HardwareDriver.json does not parse: %s" % e)
return CRITICAL
if not isinstance(entries, list):
print(" CRITICAL: HardwareDriver.json is %s, expected a list" % type(entries).__name__)
return CRITICAL
findings = Findings()
check_catalogue(entries, files, findings)
if models:
check_fleet(entries, models, findings)
print(" %d entries, %d driver files visible on the share" % (len(entries), len(files)))
if not quiet:
for i, entry in enumerate(entries):
print(" %-28s family=%-28s %s" % (
label(entry, i),
(entry.get("family") or "")[:28],
entry.get("fileName") or "(no fileName)"))
print()
order = {CRITICAL: 0, ERROR: 1, WARN: 2, INFO: 3}
for level, check, message, _ in sorted(findings.items, key=lambda f: order[f[0]]):
if quiet and level == INFO:
continue
print(" %-8s %-18s %s" % (level, check, message))
if not findings.items:
print(" clean")
print()
print(" %d critical, %d error, %d warn" % (
findings.count(CRITICAL), findings.count(ERROR), findings.count(WARN)))
print()
return findings.worst()
def main():
parser = argparse.ArgumentParser(
description="Lint HardwareDriver.json against PESetup's real driver matcher.")
parser.add_argument("--image", help="single image type (default: all on the server)")
parser.add_argument("--server", default=PXE_HOST,
help="PXE server IP (default: %s)" % PXE_HOST)
parser.add_argument("--local", metavar="PATH",
help="lint a local winpeapps tree instead of going over SSH")
parser.add_argument("--models", metavar="FILE",
help="file of WMI model strings, one per line, to resolve")
parser.add_argument("--quiet", action="store_true",
help="findings only, no per-entry listing")
args = parser.parse_args()
models = []
if args.models:
with open(args.models) as f:
models = [line.strip() for line in f
if line.strip() and not line.startswith("#")]
if args.local:
base = args.local.rstrip("/")
reader, lister = read_local, list_local
else:
base = IMAGE_BASE
reader = lambda p: read_remote(args.server, p)
lister = lambda p: list_remote(args.server, p)
if args.image:
images = [args.image]
elif args.local:
from pathlib import Path
images = sorted(d.name for d in Path(base).iterdir()
if d.is_dir() and not d.name.startswith("_"))
else:
r = ssh_cmd(args.server,
"find '%s' -maxdepth 1 -mindepth 1 -type d -printf '%%f\\n'" % base)
if r.returncode != 0:
sys.exit("ERROR: cannot list %s on %s: %s" % (base, args.server, r.stderr.strip()))
images = sorted(n for n in r.stdout.split() if not n.startswith("_"))
worst = None
order = {CRITICAL: 0, ERROR: 1, WARN: 2, INFO: 3, None: 4}
for image in images:
catalogue = reader("%s/%s/Deploy/Control/HardwareDriver.json" % (base, image))
# Drivers live under the shared tree that Deploy/Out-of-box Drivers points at.
# Listing through the image path follows the symlink, so per-image case
# differences still show up. The listing comes back relative to the drivers
# dir; destinationDir is relative to the image root, so re-anchor it.
files = ["%s/%s" % (DRIVERS_SUBDIR, f)
for f in lister("%s/%s/%s" % (base, image, DRIVERS_SUBDIR))]
result = lint_image(image, catalogue, files, models, args.quiet)
if order[result] < order[worst]:
worst = result
if worst in FAIL_LEVELS:
print("FAILED: fix the findings above before imaging.")
return 1
print("PASSED: no blocking findings.")
return 0
if __name__ == "__main__":
sys.exit(main())