Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
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.
This commit is contained in:
321
webapp/services/driver_lint.py
Normal file
321
webapp/services/driver_lint.py
Normal file
@@ -0,0 +1,321 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user