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.
This commit is contained in:
435
scripts/lint-driver-catalogue.py
Executable file
435
scripts/lint-driver-catalogue.py
Executable file
@@ -0,0 +1,435 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user