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.
275 lines
9.4 KiB
Python
275 lines
9.4 KiB
Python
"""Which hardware models the PXE server can image, and which it cannot.
|
|
|
|
WinPE (playbook/winpe-status-push.ps1) reports each machine's WMI model,
|
|
manufacturer, Dell system SKU and BIOS version to /hardware/seen before
|
|
imaging starts. This module records the models seen and checks each one
|
|
against what the server actually has:
|
|
|
|
* drivers: every image's Deploy/Control/HardwareDriver.json, matched the
|
|
way PESetup's GetDriverByModel matches (scripts/lint-driver-catalogue.py
|
|
reimplements it and documents why), plus whether the pack's zip is on
|
|
disk;
|
|
* BIOS: the WinPE BIOS manifest (models.txt, substring match like
|
|
check-bios.cmd) and whether the machine is already on that version.
|
|
|
|
A model missing drivers for an image, or with no BIOS entry, becomes a
|
|
technician to-do until it is fixed (the check re-runs on every page load,
|
|
so adding the pack clears it) or dismissed.
|
|
|
|
State: one JSON file, config.HARDWARE_STATE, written atomically:
|
|
{"models": {model: {manufacturer, skus[], serials[], bios_versions[],
|
|
pctypes[], first_seen, last_seen, count}},
|
|
"dismissed": {model: {"ts"}}}
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import tempfile
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from pathlib import PurePosixPath
|
|
|
|
import config
|
|
|
|
# Images whose HardwareDriver.json is checked, with the Windows they deploy.
|
|
# All three deploy Windows 11 now (engineer moved off Win10), so every one
|
|
# counts toward a model's gaps.
|
|
IMAGES = (
|
|
("gea-standard", "Win11"),
|
|
("gea-shopfloor", "Win11 LTSC"),
|
|
("gea-engineer", "Win11"),
|
|
)
|
|
KEEP = 10 # serials / versions remembered per model
|
|
|
|
|
|
# --- PESetup's matcher (see scripts/lint-driver-catalogue.py) ---------------
|
|
|
|
def _model_family(model):
|
|
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_matches(entry, model):
|
|
family = _model_family(model)
|
|
if family and family.lower() not in str(entry.get("family") or "").lower():
|
|
return False
|
|
# Not trimmed, case-insensitive substring: what the C# does.
|
|
return any(t.lower() in model.lower()
|
|
for t in str(entry.get("modelswminame") or "").split(","))
|
|
|
|
|
|
def _zip_relpath(entry):
|
|
dest = (str(entry.get("destinationDir") or entry.get("DestinationDir") or "")
|
|
.replace("*destinationdir*\\", "").replace("*destinationdir*", "")
|
|
.replace("\\", "/").strip("/"))
|
|
name = str(entry.get("fileName") or entry.get("FileName") or "")
|
|
return str(PurePosixPath(dest) / name) if name else None
|
|
|
|
|
|
def _exists_ci(root, relpath):
|
|
"""Path exists, matching each component case-insensitively (Dell_11/
|
|
Optiplex vs OptiPlex has split driver trees before)."""
|
|
cur = root
|
|
for part in PurePosixPath(relpath).parts:
|
|
try:
|
|
names = os.listdir(cur)
|
|
except OSError:
|
|
return False
|
|
hit = next((n for n in names if n.lower() == part.lower()), None)
|
|
if hit is None:
|
|
return False
|
|
cur = os.path.join(cur, hit)
|
|
return os.path.isfile(cur)
|
|
|
|
|
|
_catalog_cache = {"at": 0.0, "value": None}
|
|
|
|
|
|
def _catalogs(ttl=60.0):
|
|
now = time.monotonic()
|
|
if _catalog_cache["value"] is None or now - _catalog_cache["at"] > ttl:
|
|
cats = {}
|
|
for image, _ in IMAGES:
|
|
path = os.path.join(config.SAMBA_SHARE, image, "Deploy", "Control", "HardwareDriver.json")
|
|
try:
|
|
with open(path, encoding="utf-8-sig") as f:
|
|
data = json.load(f)
|
|
cats[image] = data if isinstance(data, list) else []
|
|
except (OSError, ValueError):
|
|
cats[image] = None # image not present on this server
|
|
_catalog_cache.update(at=now, value=cats)
|
|
return _catalog_cache["value"]
|
|
|
|
|
|
def driver_status(model):
|
|
"""Per image: {"status": ok|no-entry|zip-missing|no-image, "file": ...}."""
|
|
out = {}
|
|
for image, os_label in IMAGES:
|
|
entries = _catalogs().get(image)
|
|
if entries is None:
|
|
out[image] = {"status": "no-image", "os": os_label}
|
|
continue
|
|
entry = next((e for e in entries if _entry_matches(e, model)), None)
|
|
if entry is None:
|
|
out[image] = {"status": "no-entry", "os": os_label}
|
|
continue
|
|
rel = _zip_relpath(entry)
|
|
root = os.path.join(config.SAMBA_SHARE, image)
|
|
ok = bool(rel) and _exists_ci(root, rel)
|
|
out[image] = {"status": "ok" if ok else "zip-missing", "os": os_label,
|
|
"file": entry.get("fileName") or entry.get("FileName") or ""}
|
|
return out
|
|
|
|
|
|
def _ver(v):
|
|
parts = []
|
|
for p in str(v or "").replace("A", "").split("."):
|
|
try:
|
|
parts.append(int(p))
|
|
except ValueError:
|
|
parts.append(0)
|
|
return tuple(parts)
|
|
|
|
|
|
def bios_status(model, current_version=""):
|
|
"""{"status": none|update|current|file-missing, "target", "file"}."""
|
|
manifest = os.path.join(config.BIOS_DIR, "models.txt")
|
|
try:
|
|
with open(manifest, encoding="utf-8", errors="replace") as f:
|
|
lines = f.read().splitlines()
|
|
except OSError:
|
|
return {"status": "none", "target": "", "file": ""}
|
|
for line in lines:
|
|
line = line.strip()
|
|
if not line or line.startswith("#") or line.count("|") < 2:
|
|
continue
|
|
sub, bfile, target = (x.strip() for x in line.split("|")[:3])
|
|
if sub and sub.lower() in model.lower():
|
|
if not os.path.isfile(os.path.join(config.BIOS_DIR, bfile)):
|
|
return {"status": "file-missing", "target": target, "file": bfile}
|
|
if current_version and _ver(current_version) >= _ver(target):
|
|
return {"status": "current", "target": target, "file": bfile}
|
|
return {"status": "update", "target": target, "file": bfile}
|
|
return {"status": "none", "target": "", "file": ""}
|
|
|
|
|
|
# --- state ------------------------------------------------------------------
|
|
|
|
def _load():
|
|
try:
|
|
with open(config.HARDWARE_STATE) as f:
|
|
state = json.load(f)
|
|
except (OSError, ValueError):
|
|
state = {}
|
|
state.setdefault("models", {})
|
|
state.setdefault("dismissed", {})
|
|
return state
|
|
|
|
|
|
def _save(state):
|
|
folder = os.path.dirname(config.HARDWARE_STATE)
|
|
os.makedirs(folder, exist_ok=True)
|
|
fd, tmp = tempfile.mkstemp(dir=folder, prefix=".hardware-")
|
|
with os.fdopen(fd, "w") as f:
|
|
json.dump(state, f, indent=1, sort_keys=True)
|
|
os.replace(tmp, config.HARDWARE_STATE)
|
|
|
|
|
|
def _now():
|
|
return datetime.now(timezone.utc).isoformat(timespec="seconds")
|
|
|
|
|
|
def _push(lst, value):
|
|
if value and value not in lst:
|
|
lst.append(value)
|
|
del lst[:-KEEP]
|
|
|
|
|
|
def record(payload):
|
|
"""Remember one machine sighting from WinPE."""
|
|
model = " ".join(str(payload.get("model") or "").split())
|
|
if not model:
|
|
raise ValueError("missing model")
|
|
model = model[:120]
|
|
state = _load()
|
|
rec = state["models"].setdefault(model, {
|
|
"first_seen": _now(), "skus": [], "serials": [], "bios_versions": [],
|
|
"pctypes": [], "count": 0})
|
|
rec["manufacturer"] = str(payload.get("manufacturer") or rec.get("manufacturer") or "")[:60]
|
|
rec["last_seen"] = _now()
|
|
rec["count"] = int(rec.get("count") or 0) + 1
|
|
_push(rec["skus"], str(payload.get("sku") or "").strip()[:40])
|
|
_push(rec["serials"], str(payload.get("serial") or "").strip()[:64])
|
|
_push(rec["bios_versions"], str(payload.get("bios_version") or "").strip()[:40])
|
|
_push(rec["pctypes"], str(payload.get("pctype") or "").strip()[:60])
|
|
rec["last_bios_version"] = str(payload.get("bios_version") or "").strip()[:40]
|
|
_save(state)
|
|
invalidate()
|
|
|
|
|
|
def dismiss(model, done=True):
|
|
state = _load()
|
|
if model not in state["models"]:
|
|
raise ValueError("unknown model")
|
|
if done:
|
|
state["dismissed"][model] = {"ts": _now()}
|
|
else:
|
|
state["dismissed"].pop(model, None)
|
|
_save(state)
|
|
invalidate()
|
|
|
|
|
|
# Virtual machines and other non-Dell hardware have no packs by design.
|
|
_SKIP = ("virtual", "vmware", "qemu", "hyper-v", "kvm")
|
|
|
|
|
|
def coverage(include_ok=True):
|
|
"""Every model seen, newest first, with driver and BIOS status."""
|
|
state = _load()
|
|
rows = []
|
|
for model, rec in state["models"].items():
|
|
if any(s in model.lower() for s in _SKIP):
|
|
continue
|
|
drivers = driver_status(model)
|
|
bios = bios_status(model, rec.get("last_bios_version", ""))
|
|
missing = [img for img, d in drivers.items()
|
|
if d["status"] in ("no-entry", "zip-missing")]
|
|
gap = bool(missing) or bios["status"] in ("none", "file-missing")
|
|
row = dict(rec, model=model, drivers=drivers, bios=bios, missing=missing,
|
|
gap=gap, dismissed=model in state["dismissed"])
|
|
if include_ok or gap:
|
|
rows.append(row)
|
|
rows.sort(key=lambda r: r.get("last_seen") or "", reverse=True)
|
|
return rows
|
|
|
|
|
|
def open_gaps():
|
|
return [r for r in coverage(include_ok=False) if not r["dismissed"]]
|
|
|
|
|
|
_count_cache = {"at": 0.0, "value": None}
|
|
|
|
|
|
def gap_count(ttl=60.0):
|
|
now = time.monotonic()
|
|
if _count_cache["value"] is None or now - _count_cache["at"] > ttl:
|
|
try:
|
|
_count_cache["value"] = len(open_gaps())
|
|
except Exception:
|
|
_count_cache["value"] = 0
|
|
_count_cache["at"] = now
|
|
return _count_cache["value"]
|
|
|
|
|
|
def invalidate():
|
|
_count_cache["value"] = None
|
|
_catalog_cache["value"] = None
|