Files
pxe-server/webapp/services/driver_map.py
cproudlock 397063f987 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.
2026-10-13 16:58:55 -04:00

417 lines
17 KiB
Python

"""The Drivers tab's view of an image's HardwareDriver.json.
PESetup reads Deploy/Control/HardwareDriver.json and takes the FIRST entry
whose modelswminame token is in the PC's WMI model (no OS filter), so the
list order matters and is kept exactly as the user arranges it.
The page edits the whole map in the browser and posts it back as a list of
entries; every entry that came from the file carries "_orig" (its 0-based
position when the page was loaded), so the server can tell an edit from a
move, an addition or a removal. Keys starting with "_" are never written.
rows(image_type, entries) per-entry facts for the table (zip on disk,
OS, lint findings, Dell update hint)
orphans(image_type, entries) zips under the image's driver folder that
this map does not reference
staged(body) validate a posted map: (entries, origs)
changes(old, new, origs) per-entry summary + JSON diff for the review
delete_orphans(image_type, rels) zips no map, undo or rollback uses
save_upload(image_type, file) an uploaded zip, temp file + rename
Writes go through services/control_files (format kept, backup, undo).
"""
import difflib
import json
import os
import tempfile
import config
from services import control_files, driver_lint, fs, image_pages
FILE_NAME = "HardwareDriver.json"
DRIVERS_SUBDIR = "Out-of-box Drivers"
DEST_PREFIX = "*destinationdir*\\Deploy\\Out-of-box Drivers\\"
# Keys the C# reads as strings; a posted entry must not turn them into
# numbers or objects.
STRING_KEYS = ("manufacturer", "product", "family", "modelswminame", "modelsfriendlyname",
"fileName", "destinationDir", "FileName", "DestinationDir", "osId", "models")
def map_path(image_type):
return image_pages.control_file(image_type, FILE_NAME)
def load(image_type):
"""(entries, error). entries is [] and error a message when the file is
missing, does not parse or is not a list."""
path = map_path(image_type)
if not os.path.isfile(path):
return [], None
try:
with open(path, encoding="utf-8-sig") as fh:
data = json.load(fh)
except (OSError, ValueError) as exc:
return [], f"{FILE_NAME} does not parse: {exc}"
if not isinstance(data, list):
return [], f"{FILE_NAME} is a {type(data).__name__}, expected a list"
# The page edits entries as objects; a string or null entry would be
# turned into an object (or crash the page), so such a map is read-only.
bad = [i + 1 for i, e in enumerate(data) if not isinstance(e, dict)]
if bad:
return [], (f"{FILE_NAME} entry #{bad[0]} is not a JSON object "
f"({len(bad)} such entr{'y' if len(bad) == 1 else 'ies'})")
return data, None
def file_sha(image_type):
return control_files.file_sha256(map_path(image_type))
def _stat(path):
try:
st = os.stat(path)
return st.st_size, st.st_mtime
except (OSError, TypeError):
return None, None
def _dell_hints():
try:
from services import dell_updates
return dell_updates.update_hints()["drivers"]
except Exception: # no scan yet or the module failed: no hints
return {}
def rows(image_type, entries):
"""(rows, findings, counts) for a list of entries (from the file or
staged in the page). One row per entry, in order:
{"os", "zip": {"status", "rel", "actual", "size", "mtime"},
"findings": [finding, ...], "dell": newer Dell version or ""}"""
dicts = [e if isinstance(e, dict) else {} for e in entries]
zips = [driver_lint.zip_state(image_type, e) for e in dicts]
findings = driver_lint.lint_entries(entries, image_type, zips=zips)
per = [[] for _ in entries]
for f in findings:
for i in f["indexes"]:
if 0 <= i < len(per):
per[i].append({"level": f["level"], "check": f["check"], "message": f["message"],
"label": driver_lint.CHECK_LABELS.get(f["check"], f["check"])})
hints = _dell_hints()
out = []
for i, e in enumerate(dicts):
z = zips[i]
size, mtime = _stat(z["path"])
name = str(e.get("fileName") or e.get("FileName") or "")
out.append({
"os": image_pages.driver_os(e),
"zip": {"status": z["status"], "rel": z["rel"], "actual": z.get("actual", ""),
"size": size, "mtime": mtime},
"findings": per[i],
"dell": hints.get(name.lower(), ""),
})
return out, findings, driver_lint.counts(findings)
# --- orphans ------------------------------------------------------------------------
def _drivers_root(image_type):
"""Real path of the image's Out-of-box Drivers folder (a symlink into
_shared on the server), or None."""
path = os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR)
real = os.path.realpath(path)
return real if os.path.isdir(real) else None
def _share_images():
"""Every image whose map can point into the shared driver tree: the
registered ones plus any other folder on the share with a map (an image
dropped from the registry can still be booted)."""
images = list(config.IMAGE_TYPES)
try:
names = sorted(os.listdir(config.SAMBA_SHARE))
except OSError:
names = []
for name in names:
if (not name.startswith("_") and name not in images
and os.path.isfile(map_path(name))):
images.append(name)
return images
def _restore_points():
"""[(image, label, map file)] for maps the app can still put back: the
backup behind each image's Undo last save, and the map backups of every
Dell update that can be rolled back. A zip those maps use must not be
deleted, or the undo / rollback restores an entry with no zip."""
out = []
for image in _share_images():
rec = control_files.last_save(image, "drivers") or {}
for f in rec.get("files", []):
if f.get("backup") and os.path.basename(f.get("path", "")) == FILE_NAME:
out.append((image, f"{image} (undo of last save)", f["backup"]))
try:
from services import dell_updates
history = dell_updates.history()
except Exception: # no Dell history yet or the module failed
history = []
for rec in history:
if rec.get("rolled_back"):
continue
for path, bak in (rec.get("backups") or {}).items():
if os.path.basename(path) != FILE_NAME or not bak:
continue
image = os.path.relpath(path, config.SAMBA_SHARE).split(os.sep)[0]
out.append((image, f"{image} (Dell update rollback)", bak))
return out
def _entries_of(path):
try:
with open(path, encoding="utf-8-sig") as fh:
data = json.load(fh)
except (OSError, ValueError):
return []
return [e for e in data if isinstance(e, dict)] if isinstance(data, list) else []
def _referenced():
"""{real zip path: [who uses it]}: every image's map, plus the maps an
undo or a Dell rollback would restore (see _restore_points)."""
refs = {}
sources = [(image, image, map_path(image)) for image in _share_images()]
for image, label, path in sources + _restore_points():
for e in _entries_of(path):
p = image_pages.zip_path(image, e)
if p:
refs.setdefault(os.path.realpath(p), []).append(label)
return refs
def orphans(image_type, refs=None):
"""Zips under this image's driver folder that its map does not use.
used_by lists the other images that do use it (deleting those would
break them, so the page only allows Add to map)."""
root = _drivers_root(image_type)
if not root:
return []
refs = _referenced() if refs is None else refs
out = []
# followlinks=False: Dell_11/OptiPlex can be a symlink to Optiplex, and
# following it would list every zip there twice.
for dirpath, dirnames, filenames in os.walk(root):
dirnames.sort()
for fn in sorted(filenames):
if not fn.lower().endswith(".zip"):
continue
full = os.path.join(dirpath, fn)
users = sorted(set(refs.get(os.path.realpath(full), [])))
if image_type in users:
continue
rel = os.path.relpath(full, root).replace(os.sep, "/")
folder = os.path.dirname(rel)
size, mtime = _stat(full)
out.append({
"name": fn,
"rel": rel,
"folder": folder,
"dest": DEST_PREFIX.rstrip("\\") + ("\\" + folder.replace("/", "\\") if folder else ""),
"size": size,
"mtime": mtime,
"used_by": users,
})
return out
def delete_orphans(image_type, rels):
"""Delete zips (paths relative to the driver folder) that NO image's
map references. Returns {"removed": [...], "refused": [(rel, why)]}."""
root = _drivers_root(image_type)
removed, refused = [], []
if not root:
return {"removed": removed, "refused": [(r, "no driver folder") for r in rels]}
refs = _referenced()
for rel in rels:
rel = str(rel or "")
full = os.path.realpath(os.path.join(root, rel))
if not full.startswith(root + os.sep) or not full.lower().endswith(".zip"):
refused.append((rel, "not a zip in the driver folder"))
elif os.path.islink(os.path.join(root, rel)):
# Unlinking the resolved path would delete the zip it points at.
refused.append((rel, "a symlink; remove it by hand"))
elif not os.path.isfile(full):
refused.append((rel, "not found"))
elif refs.get(full):
refused.append((rel, "used by " + ", ".join(sorted(set(refs[full])))))
else:
try:
os.unlink(full)
removed.append(rel)
except OSError as exc:
refused.append((rel, str(exc)))
return {"removed": removed, "refused": refused}
def save_upload(image_type, upload, overwrite=False):
"""Store an uploaded driver .zip at the top of the image's driver folder.
Written to a temp file next to it and renamed, so a PC copying the old
file never reads a half-written one; owner and group follow the folder
(the app runs as root, the share is pxe:pxe). Overwriting a zip any map,
undo or rollback uses is refused: its entry's hash and size would no
longer match. Nothing is added to the map (Add to map does that, through
the review step). Returns {"filename", "path", "replaced"}."""
from services import images # the existing upload name sanitizer
name = images._safe_filename(getattr(upload, "filename", ""))
if not name.lower().endswith(".zip"):
raise ValueError("Driver upload must be a .zip file")
root = _drivers_root(image_type)
if not root:
root = os.path.join(fs.deploy_path(image_type), DRIVERS_SUBDIR)
os.makedirs(root, exist_ok=True)
dst = os.path.join(root, name)
replaced = os.path.lexists(dst)
if replaced:
if not overwrite:
raise FileExistsError(f"{name} already exists in {DRIVERS_SUBDIR}/")
if os.path.islink(dst) or not os.path.isfile(dst):
raise ValueError(f"{name} in {DRIVERS_SUBDIR}/ is not a plain file; replace it by hand")
users = sorted(set(_referenced().get(os.path.realpath(dst), [])))
if users:
raise ValueError(f"{name} is in use ({', '.join(users)}); upload it under a new name "
"and point the entry at it instead")
fd, tmp = tempfile.mkstemp(dir=root, prefix=".webui-upload-", suffix=".part")
try:
with os.fdopen(fd, "wb") as fh:
upload.save(fh)
fh.flush()
os.fsync(fh.fileno())
os.chmod(tmp, 0o664)
try:
st = os.stat(root)
os.chown(tmp, st.st_uid, st.st_gid)
except OSError:
pass # not root (local tests)
os.replace(tmp, dst)
except BaseException:
if os.path.exists(tmp):
os.remove(tmp)
raise
return {"filename": name, "path": dst, "replaced": replaced}
# --- staged map -----------------------------------------------------------------------
def staged(body, old_len):
"""Validate a posted {"entries": [...]}. Returns (entries, origs, moved):
entries cleaned of "_" keys, origs[i] = position in the file the entry
came from (or None for a new one), moved[i] = the user moved it with
the arrows ("_moved"; only used to word the review). Raises ValueError."""
raw = body.get("entries") if isinstance(body, dict) else None
if not isinstance(raw, list) or not all(isinstance(e, dict) for e in raw):
raise ValueError("expected entries: a list of objects")
entries, origs, moved, seen = [], [], [], set()
for n, e in enumerate(raw, 1):
o = e.get("_orig")
if isinstance(o, bool) or not isinstance(o, int) or not 0 <= o < old_len or o in seen:
o = None
else:
seen.add(o)
clean = {k: v for k, v in e.items() if not str(k).startswith("_")}
for k in STRING_KEYS:
if k in clean and clean[k] is not None and not isinstance(clean[k], str):
raise ValueError(f"entry #{n}: {k} must be text")
if "aOsIds" in clean and not (isinstance(clean["aOsIds"], list)
and all(isinstance(x, str) for x in clean["aOsIds"])):
raise ValueError(f"entry #{n}: aOsIds must be a list of text")
entries.append(clean)
origs.append(o)
moved.append(bool(e.get("_moved")))
return entries, origs, moved
def _dump(entry):
return json.dumps(entry, indent=4, ensure_ascii=False)
def _kept_in_order(seq, weights):
"""Indexes (into seq) of the heaviest increasing run of values: the
entries that kept their relative order; the rest were moved. When the
user swaps two neighbours either one could be "the moved one", so the
entries the user did not touch weigh more and stay in the run."""
n = len(seq)
if not n:
return set()
best = list(weights)
prev = [-1] * n
for i in range(n):
for j in range(i):
if seq[j] < seq[i] and best[j] + weights[i] > best[i]:
best[i], prev[i] = best[j] + weights[i], j
i = max(range(n), key=lambda k: best[k])
keep = set()
while i != -1:
keep.add(i)
i = prev[i]
return keep
def changes(old, new, origs, user_moved=None):
"""What a save would do, entry by entry, for the review step:
[{"kind": "added"|"removed"|"edited"|"moved"|"edited+moved",
"label", "old_pos", "new_pos" (1-based or None), "file" (moves only),
"fields", "diff"}]
and a summary {"added", "removed", "edited", "moved"}."""
out = []
kept = [(i, o) for i, o in enumerate(origs) if o is not None]
user_moved = user_moved or [False] * len(new)
in_order = _kept_in_order([o for _, o in kept],
[1 if user_moved[i] else 1000 for i, _ in kept])
moved = {kept[k][0] for k in range(len(kept)) if k not in in_order}
summary = {"added": 0, "removed": 0, "edited": 0, "moved": 0}
for i, (entry, o) in enumerate(zip(new, origs)):
lbl = driver_lint.label(entry, i)
if o is None:
summary["added"] += 1
out.append({"kind": "added", "label": lbl, "old_pos": None, "new_pos": i + 1,
"fields": list(entry.keys()),
"diff": _udiff("", _dump(entry) + "\n", "(new)", f"#{i + 1}")})
continue
before = old[o] if isinstance(old[o], dict) else {}
edited = _dump(before) != _dump(entry)
was_moved = i in moved
if not edited and not was_moved:
continue
kind = "edited+moved" if edited and was_moved else ("edited" if edited else "moved")
if edited:
summary["edited"] += 1
if was_moved:
summary["moved"] += 1
fields = sorted({k for k in set(before) | set(entry) if before.get(k) != entry.get(k)}
| ({"(key order)"} if edited and list(before) != list(entry)
and set(before) == set(entry) else set()))
out.append({"kind": kind, "label": lbl, "old_pos": o + 1, "new_pos": i + 1,
"file": str(entry.get("fileName") or entry.get("FileName") or ""),
"fields": fields,
"diff": _udiff(_dump(before) + "\n", _dump(entry) + "\n",
f"#{o + 1}", f"#{i + 1}") if edited else ""})
gone = sorted(set(range(len(old))) - {o for o in origs if o is not None})
for o in gone:
before = old[o] if isinstance(old[o], dict) else {}
summary["removed"] += 1
out.append({"kind": "removed", "label": driver_lint.label(before, o), "old_pos": o + 1,
"new_pos": None, "fields": [],
"diff": _udiff(_dump(before) + "\n", "", f"#{o + 1}", "(removed)")})
out.sort(key=lambda c: (c["new_pos"] or c["old_pos"] or 0, c["kind"] != "removed"))
return out, summary
def _udiff(a, b, from_label, to_label):
# Whole entry as context: an entry is about 20 lines, and a reviewer
# wants to see which model a changed line belongs to.
return "".join(difflib.unified_diff(a.splitlines(keepends=True), b.splitlines(keepends=True),
fromfile=from_label, tofile=to_label, n=60))