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.
417 lines
17 KiB
Python
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))
|