Updates group: - Dell drivers and BIOS: check Dell's catalogs against all three driver maps and models.txt, download with a shared speed cap, repack, preview, apply with backups and rollback. - iPXE boot files: check GitHub for the signed Secure Boot set, verify the sha256 and each file's signer, install atomically, roll back. Includes the by-hand procedure. - Blancco: upload a release, stage it, test-boot it from the menu, activate, roll back. - BPRT sync: validate a batch uploaded from the MediaCreator Lite cache (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site overlay files kept. Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and Packages tabs. Every save shows a diff first, keeps the file's format, makes a backup and can be undone. Driver lint runs inline. Enrollment: upload standard/engineer MCL packages, one per tenant/purpose, with archive and restore. Hardware coverage counts engineer as Win11.
322 lines
14 KiB
Python
322 lines
14 KiB
Python
"""Lint an image's HardwareDriver.json against PESetup's real driver matcher.
|
|
|
|
A port of scripts/lint-driver-catalogue.py for the webapp (the script is
|
|
left as it is: it still gates deploys from a workstation). Same matcher,
|
|
same checks, with three differences that come from running on the server:
|
|
|
|
* zips are looked up on the share directly, and a zip found only with
|
|
different letter case is a WARN, not an ERROR, when WinPE still finds
|
|
it: WinPE reads the share over SMB and Samba matches each folder name
|
|
case-insensitively for Windows clients (default "case sensitive =
|
|
auto"). It stays an ERROR when a folder with the exact spelling exists
|
|
and the zip sits in its other-case twin;
|
|
* a missing (null) modelswminame or family is CRITICAL, not just "no
|
|
tokens": the C# calls .Split / .ToLower on them, which throws for every
|
|
model the family filter lets through;
|
|
* "win10-first": a Win11 entry's model is answered by a Win10 entry
|
|
earlier in the list. Every GE image deploys Windows 11 and PESetup
|
|
has no OS filter, so those PCs get Windows 10 drivers.
|
|
|
|
Positions in messages are 1-based (#5 = fifth entry), as on the Drivers tab.
|
|
|
|
lint_entries(entries, image_type) -> findings, each a dict:
|
|
{"level", "check", "message", "index" (0-based or None),
|
|
"indexes" (every 0-based entry the finding is about)}
|
|
lint_image(image_type) reads the map from disk (used by the Overview tab).
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
from pathlib import PurePosixPath
|
|
|
|
from services import fs, image_pages
|
|
|
|
CRITICAL, ERROR, WARN, INFO = "CRITICAL", "ERROR", "WARN", "INFO"
|
|
LEVELS = (CRITICAL, ERROR, WARN, INFO)
|
|
LEVEL_ORDER = {lvl: i for i, lvl in enumerate(LEVELS)}
|
|
|
|
# Short names for the checks, shown as badges on the Drivers tab.
|
|
CHECK_LABELS = {
|
|
"virtual-platform": "virtual platform",
|
|
"null-tokens": "no modelswminame",
|
|
"null-family": "no family",
|
|
"empty-token": "empty token",
|
|
"token-whitespace": "token whitespace",
|
|
"no-tokens": "no tokens",
|
|
"family-mismatch": "family mismatch",
|
|
"no-filename": "no file name",
|
|
"missing-zip": "zip missing",
|
|
"case-mismatch": "zip case differs",
|
|
"win10-first": "Win10 entry wins",
|
|
"shadowed": "shadowed",
|
|
"duplicate-token": "duplicate token",
|
|
"bad-entry": "not an object",
|
|
}
|
|
|
|
|
|
# --- PESetup's matcher (docs/PESETUP-INTERNALS.md, GetDriverByModel) ---------
|
|
|
|
def model_family(model):
|
|
"""Three Contains() tests, the last hit 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.
|
|
A missing (null) modelswminame gives no tokens here; PESetup throws on
|
|
it instead, which the null-tokens check reports."""
|
|
if entry.get("modelswminame") is None:
|
|
return []
|
|
return str(entry.get("modelswminame")).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
|
|
# model.ToLower().Contains(token.ToLower()); "" is in every string.
|
|
return any(t.lower() in model.lower() for t in entry_tokens(entry))
|
|
|
|
|
|
def select_driver(entries, model):
|
|
"""First match wins, like GetDriverByModel: (index, entry) or (None, None)."""
|
|
for i, entry in enumerate(entries):
|
|
if entry_matches(entry, model):
|
|
return i, entry
|
|
return None, None
|
|
|
|
|
|
# --- helpers -------------------------------------------------------------------
|
|
|
|
def label(entry, index):
|
|
name = entry.get("modelsfriendlyname") or entry.get("modelswminame") or "?"
|
|
return "#%d %s" % (index + 1, name)
|
|
|
|
|
|
def _smb_lookup(root, rel):
|
|
"""Resolve rel the way Samba does for a Windows client: per folder, the
|
|
exact name when it exists, else the one name that differs only in case.
|
|
No backtracking: once a folder is picked, a file under its other-case
|
|
twin is not found (Dell_11 has held both Optiplex and OptiPlex)."""
|
|
cur = root
|
|
for part in PurePosixPath(rel).parts:
|
|
nxt = os.path.join(cur, part)
|
|
if not os.path.lexists(nxt):
|
|
try:
|
|
names = [n for n in os.listdir(cur) if n.lower() == part.lower()]
|
|
except OSError:
|
|
return None
|
|
if len(names) != 1:
|
|
return None
|
|
nxt = os.path.join(cur, names[0])
|
|
cur = nxt
|
|
return cur if os.path.isfile(cur) else None
|
|
|
|
|
|
def zip_state(image_type, entry):
|
|
"""Where an entry's zip is: {"rel", "status", "path"}. rel is relative
|
|
to the image root (Deploy/Out-of-box Drivers/...). status:
|
|
ok the exact path exists
|
|
case only with other letter case, and WinPE (SMB) still finds it
|
|
trap on disk under another-case folder that SMB does not pick
|
|
missing nowhere
|
|
none the entry names no file"""
|
|
rel = image_pages.hardware_coverage._zip_relpath(entry)
|
|
if not rel:
|
|
return {"rel": "", "status": "none", "path": None}
|
|
if ".." in PurePosixPath(rel).parts:
|
|
# Never look outside the image tree: /drivers/check takes any
|
|
# posted entry, and no real entry climbs out with "..".
|
|
return {"rel": rel, "status": "missing", "path": None}
|
|
root = fs.image_root(image_type)
|
|
exact = os.path.join(root, rel)
|
|
if os.path.isfile(exact):
|
|
return {"rel": rel, "status": "ok", "path": exact}
|
|
smb = _smb_lookup(root, rel)
|
|
if smb:
|
|
return {"rel": rel, "status": "case", "path": smb, "actual": os.path.relpath(smb, root)}
|
|
found = image_pages.zip_path(image_type, entry)
|
|
if found:
|
|
return {"rel": rel, "status": "trap", "path": found, "actual": os.path.relpath(found, root)}
|
|
return {"rel": rel, "status": "missing", "path": None}
|
|
|
|
|
|
def _add(out, level, check, message, index=None, indexes=None):
|
|
idx = list(indexes) if indexes is not None else ([] if index is None else [index])
|
|
out.append({"level": level, "check": check, "message": message,
|
|
"index": index, "indexes": idx})
|
|
|
|
|
|
# --- checks --------------------------------------------------------------------
|
|
|
|
def lint_entries(entries, image_type=None, zips=None):
|
|
"""Every finding for a list of entries. image_type enables the zip
|
|
checks; zips (a list of zip_state results, one per entry) saves
|
|
looking them up again."""
|
|
out = []
|
|
rows = [e if isinstance(e, dict) else {} for e in entries]
|
|
for i, e in enumerate(entries):
|
|
if not isinstance(e, dict):
|
|
_add(out, CRITICAL, "bad-entry",
|
|
"#%d is not a JSON object; PESetup cannot read the map." % (i + 1), i)
|
|
|
|
for i, entry in enumerate(rows):
|
|
tag = label(entry, i)
|
|
|
|
# Virtual platform hijack: one such entry anywhere flips PESetup into
|
|
# virtual-only mode and every physical machine fails.
|
|
manufacturer = str(entry.get("manufacturer") or "")
|
|
if "virtual platform" in manufacturer.lower():
|
|
_add(out, CRITICAL, "virtual-platform",
|
|
"%s manufacturer is %r. PESetup then takes the first entry as the driver and "
|
|
"REQUIRES the machine to look virtual, so every physical PC fails." % (tag, manufacturer), i)
|
|
|
|
# Null fields the C# dereferences without a check.
|
|
if entry.get("modelswminame") is None:
|
|
_add(out, CRITICAL, "null-tokens",
|
|
"%s has no modelswminame. PESetup calls Split() on it and throws for every "
|
|
"model the family filter lets through, so driver selection fails." % tag, i)
|
|
if entry.get("family") is None:
|
|
_add(out, CRITICAL, "null-family",
|
|
"%s has no family. PESetup calls ToLower() on it for every Latitude, OptiPlex "
|
|
"and Precision model and throws." % tag, i)
|
|
|
|
# Empty token: Contains("") is true for every model. An empty
|
|
# modelswminame is one empty token, so it matches everything too.
|
|
tokens = entry_tokens(entry)
|
|
if tokens == [""]:
|
|
_add(out, CRITICAL, "empty-token",
|
|
"%s modelswminame is empty. Split gives one empty token, which matches every "
|
|
"model, so this entry takes over the map." % tag, i)
|
|
elif any(t == "" for t in tokens):
|
|
_add(out, CRITICAL, "empty-token",
|
|
"%s modelswminame %r has an EMPTY token (trailing or doubled comma). An empty "
|
|
"token matches every model, so this entry takes over the map."
|
|
% (tag, entry.get("modelswminame")), i)
|
|
|
|
# Whitespace around a token is kept by Split(','), so it must be in the model.
|
|
for token in tokens:
|
|
if token and token != token.strip():
|
|
_add(out, ERROR, "token-whitespace",
|
|
"%s token %r carries whitespace. PESetup does not trim, so the model "
|
|
"string must contain the space too; this token will usually miss." % (tag, token), i)
|
|
|
|
if tokens and tokens != [""] and not str(entry.get("modelswminame")).strip():
|
|
_add(out, ERROR, "no-tokens",
|
|
"%s has an empty modelswminame, so no model can ever select it." % tag, i)
|
|
|
|
# The family filter runs on the MODEL first, then wants entry.family
|
|
# to contain it: a token implying another family can never match.
|
|
family_field = str(entry.get("family") or "")
|
|
for token in tokens:
|
|
token = token.strip()
|
|
implied = model_family(token) if token else ""
|
|
if implied and implied.lower() not in family_field.lower():
|
|
_add(out, 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)
|
|
|
|
# The referenced zip.
|
|
if not str(entry.get("fileName") or entry.get("FileName") or ""):
|
|
_add(out, ERROR, "no-filename",
|
|
"%s has no fileName, so PESetup has nothing to unzip." % tag, i)
|
|
elif image_type:
|
|
z = zips[i] if zips is not None else zip_state(image_type, entry)
|
|
if z["status"] == "missing":
|
|
_add(out, ERROR, "missing-zip",
|
|
"%s references %s, which is not on the share. A driver miss is only a "
|
|
"warning in PESetup, so imaging finishes with no drivers." % (tag, z["rel"]), i)
|
|
elif z["status"] == "case":
|
|
_add(out, WARN, "case-mismatch",
|
|
"%s references %s but the share holds %s. WinPE finds it (Samba matches "
|
|
"case-insensitively), Linux tools and the lint script do not."
|
|
% (tag, z["rel"], z.get("actual", "")), i)
|
|
elif z["status"] == "trap":
|
|
_add(out, ERROR, "case-mismatch",
|
|
"%s references %s but the zip is at %s. A folder with the exact spelling "
|
|
"exists and does not hold it, so WinPE will not find it."
|
|
% (tag, z["rel"], z.get("actual", "")), i)
|
|
|
|
# Shadowing: replay each entry's own tokens through the matcher. When a
|
|
# Win10 entry answers a Win11 entry's model, that is worse than dead
|
|
# weight: Win11 PCs get Windows 10 drivers.
|
|
os_of = [image_pages.driver_os(e) for e in rows]
|
|
for i, entry in enumerate(rows):
|
|
for token in entry_tokens(entry):
|
|
token = token.strip()
|
|
if not token:
|
|
continue
|
|
win, _ = select_driver(rows, token)
|
|
if win is None or win == i:
|
|
continue
|
|
if os_of[i] == "Win11" and os_of[win] == "Win10":
|
|
_add(out, ERROR, "win10-first",
|
|
"%s token %r is answered by %s, a Windows 10 pack earlier in the list. "
|
|
"PESetup has no OS filter, so Windows 11 PCs get Windows 10 drivers. "
|
|
"Move the Win11 entry above it." % (label(entry, i), token, label(rows[win], win)),
|
|
i, indexes=[i, win])
|
|
else:
|
|
_add(out, WARN, "shadowed",
|
|
"%s token %r is answered by %s instead (first match wins)."
|
|
% (label(entry, i), token, label(rows[win], win)), i)
|
|
|
|
# One token claimed by several entries: the first one wins whatever the OS.
|
|
by_token = {}
|
|
for i, entry in enumerate(rows):
|
|
for token in entry_tokens(entry):
|
|
token = token.strip().lower()
|
|
if token:
|
|
by_token.setdefault(token, [])
|
|
if i not in by_token[token]:
|
|
by_token[token].append(i)
|
|
for token, idxs in sorted(by_token.items()):
|
|
if len(idxs) > 1:
|
|
detail = ", ".join("%s (%s, osId %s)" % (label(rows[i], i), os_of[i], rows[i].get("osId"))
|
|
for i in idxs)
|
|
_add(out, WARN, "duplicate-token",
|
|
"token %r is claimed by %d entries: %s. osId is not part of the match, so %s "
|
|
"wins whatever OS is applied." % (token, len(idxs), detail, label(rows[idxs[0]], idxs[0])),
|
|
None, indexes=idxs)
|
|
|
|
out.sort(key=lambda f: (LEVEL_ORDER[f["level"]], f["indexes"][0] if f["indexes"] else -1))
|
|
return out
|
|
|
|
|
|
def counts(findings):
|
|
"""{"total", "CRITICAL": n, "ERROR": n, "WARN": n, "INFO": n}."""
|
|
c = {lvl: 0 for lvl in LEVELS}
|
|
for f in findings:
|
|
c[f["level"]] += 1
|
|
c["total"] = sum(c[lvl] for lvl in LEVELS)
|
|
return c
|
|
|
|
|
|
def map_path(image_type):
|
|
return image_pages.control_file(image_type, "HardwareDriver.json")
|
|
|
|
|
|
def lint_image(image_type):
|
|
"""Findings for the map on disk. [] when the image has no map; one
|
|
CRITICAL finding when it does not parse or is not a list."""
|
|
path = map_path(image_type)
|
|
if not os.path.isfile(path):
|
|
return []
|
|
try:
|
|
with open(path, encoding="utf-8-sig") as fh:
|
|
entries = json.load(fh)
|
|
except (OSError, ValueError) as exc:
|
|
return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [],
|
|
"message": "HardwareDriver.json does not parse: %s" % exc}]
|
|
if not isinstance(entries, list):
|
|
return [{"level": CRITICAL, "check": "parse", "index": None, "indexes": [],
|
|
"message": "HardwareDriver.json is a %s, expected a list" % type(entries).__name__}]
|
|
return lint_entries(entries, image_type)
|