"""Keep the Dell Win11 driver packs and the WinPE BIOS files current. What it manages: * driver packs: every Win11 entry in the three images' HardwareDriver.json (gea-standard, gea-engineer, gea-shopfloor; standard and engineer are byte-identical and share the zips under _shared/Out-of-box Drivers); * BIOS: the WinPE manifest models.txt in BIOS_DIR (`ModelSubstring|BIOSFile|Version`, first substring match wins in check-bios.cmd, so lines are never reordered). Where the answers come from (both cabs are plain XML inside, read with 7z): * https://downloads.dell.com/catalog/DriverPackCatalog.cab: one DriverPackage per pack, with OS codes, supported models and hashes; * https://downloads.dell.com/catalog/CatalogIndexPC.cab: one GroupManifest per system ID pointing at a small per-model cab whose BIOS SoftwareComponent is the latest BIOS. Per-model cabs are cached and checked against the sha256 the index gives. (CatalogPC.cab is stale and is not used.) The flow, each step a function here and a button on /updates/dell: 1. check() background job: fetch catalogs, write scan.json; 2. download() background job: fetch the selected packs/BIOS at the speed cap, verify Dell's sha256 (md5 fallback), turn each driver exe into the deflate zip PESetup expects, stage it; 3. preview()/apply(): place staged files next to the files they replace (rename; staging and share are one filesystem), rewrite all three maps and models.txt with backups, keep history; 4. rollback(): put the maps / models.txt back from those backups. Old zips and exes are never deleted. Run as a background job: python3 -m services.dell_updates check|download|apply """ from __future__ import annotations import fcntl import glob import hashlib import json import os import re import shutil import subprocess import sys import tempfile import xml.etree.ElementTree as ET from datetime import datetime from pathlib import PurePosixPath import config from services import updates_common as uc NS = "{openmanage/cm/dm}" DELL = "https://downloads.dell.com/" CATALOGS = { "drivers": "catalog/DriverPackCatalog.cab", "index": "catalog/CatalogIndexPC.cab", } # Images whose driver maps are scanned and rewritten (all deploy Win11). IMAGES = ("gea-standard", "gea-engineer", "gea-shopfloor") # PESetup osId values that mean Windows 11 (18 is Windows 10). WIN11_OS_IDS = {"19", "20", "21"} # Background job unit names (systemd-run --unit). UNITS = {"check": "pxe-dell-check", "download": "pxe-dell-download"} # Index brands that are never on this site's floor; skipping them keeps # substrings like "7090" from matching consumer models. _SKIP_BRANDS = re.compile(r"inspiron|xps|vostro|alienware|g series|chromebook|wyse|edge|poweredge", re.I) # Seen models that have no Dell packs by design. _SKIP_MODELS = ("virtual", "vmware", "qemu", "hyper-v", "kvm") # --- paths and small helpers -------------------------------------------------- def _dir(*parts): return uc.state_dir("dell", *parts) def _path(name): return os.path.join(_dir(), name) def _stamp(): return datetime.now().strftime("%Y%m%d-%H%M%S") def _sha256(path): return uc.file_hash(path, "sha256").upper() def _alnum(s): return re.sub(r"[^a-z0-9]", "", str(s or "").lower()) def _nice(): """Prefix for CPU/disk heavy commands so imaging traffic keeps priority.""" cmd = [] if shutil.which("ionice"): cmd += ["ionice", "-c", "3"] if shutil.which("nice"): cmd += ["nice", "-n", "15"] return cmd def _cab_root(cab): """Parse the XML inside a Dell cab (UTF-8 or UTF-16, 7z extracts both).""" r = subprocess.run(["7z", "e", "-so", cab], capture_output=True) if r.returncode or not r.stdout: raise RuntimeError(f"cannot extract {os.path.basename(cab)}: " f"{r.stderr.decode(errors='replace').strip()[:200]}") return ET.fromstring(r.stdout) def _hashes(elem): """{"SHA256": hex, "MD5": hex, ...} from an element's own Cryptography.""" out = {} crypt = elem.find(NS + "Cryptography") if elem is not None else None if crypt is not None: for h in crypt.findall(NS + "Hash"): if h.text: out[(h.get("algorithm") or "").upper()] = h.text.strip() return out def _int(v): try: return int(v) except (TypeError, ValueError): return 0 # check-bios.cmd expands the BIOSFile and Version fields unquoted in cmd # (`set "BIOSFILE=%%B"`, `find /I "%TARGETVER%"`), so a name with & ^ % ! ( ) # or a space would break every WinPE boot. Every real Dell BIOS name fits. _CMD_SAFE = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]*") def bios_name_ok(name, version): return bool(_CMD_SAFE.fullmatch(str(name or "")) and _CMD_SAFE.fullmatch(str(version or ""))) def file_name_ok(name): """A plain file name from Dell's catalog: no path, no leading dot. Some Win11 CAB packs have spaces ('7390 2in1-win11-A00-4PY3P.CAB'), which is fine for a staging file run through argv lists.""" name = str(name or "") return (bool(name) and name.isascii() and name.isprintable() and not name.startswith(".") and "/" not in name and "\\" not in name) # --- name and version rules ----------------------------------------------------- def norm(s): """Model name for matching: lower case, no 'Dell', AIO spelled one way.""" s = str(s or "").lower().replace("all-in-one", "aio").replace("all in one", "aio") s = s.replace("optiplex", "optiplex ") s = re.sub(r"\bdell\b", "", s) s = re.sub(r"[^a-z0-9]+", " ", s) return " ".join(s.split()) def aversion(name): """Dell A-number from a pack or zip name: ..._a08.zip -> 8, None if absent.""" m = re.search(r"(?i)(?:^|[_-])a(\d+)(?=[_.\-(]|$)", str(name or "")) return int(m.group(1)) if m else None def pack_code(file_name, release_id=""): """Model code of a Dell pack: PB13250-HT8VV_Win11_1.0_A08.exe -> pb13250, OptiPlex-3000-JN664_Win11_... -> optiplex3000, and the older CAB packs '7390 2in1-win11-A00-4PY3P.CAB' -> 73902in1.""" base = os.path.basename(file_name) m = re.match(r"(?i)(.+?)[_-]win1[01]", base) stem = m.group(1) if m else base.split("_")[0] if release_id and stem.lower().endswith("-" + release_id.lower()): stem = stem[: -len(release_id) - 1] elif not release_id: stem = re.sub(r"-[A-Za-z0-9]{5}$", "", stem) return _alnum(stem) def zip_name(file_name, release_id, dell_version): """win11__.zip, the name the driver zip is staged under.""" return f"win11_{pack_code(file_name, release_id)}_{_alnum(dell_version)}.zip" def vtuple(v): """Comparable BIOS version: '1.45.0' -> (1, 45, 0), '02.12.00' -> (2, 12, 0), 'A22' -> (22,).""" v = str(v or "").strip() m = re.fullmatch(r"(?i)a(\d+)", v) if m: return (int(m.group(1)),) return tuple(int(x) for x in re.findall(r"\d+", v)) def norm_version(v): """Version as models.txt writes it: dotted numbers without leading zeros.""" v = str(v or "").strip() if re.fullmatch(r"[\d._]+", v): return ".".join(str(int(x)) for x in re.split(r"[._]", v) if x) return v def bios_stem(file_name): """BIOS file family: the name with its version taken out, lower case. Latitude_5430_7330_Rugged_1.45.0.exe -> latitude_5430_7330_rugged_#.exe""" base = os.path.basename(str(file_name or "")).lower() hits = list(re.finditer(r"\d+(?:[._]\d+){1,3}", base)) if hits: m = hits[-1] return base[: m.start()] + "#" + base[m.end():] return re.sub(r"a\d+(?=\.exe$)", "#", base) # --- reading the share ---------------------------------------------------------- def map_path(image): return os.path.join(config.SAMBA_SHARE, image, "Deploy", "Control", "HardwareDriver.json") def read_maps(errors=None): """{image: {"path", "raw", "entries"}} for every image map present. A map that exists but cannot be read or parsed is left out and, when errors is a list, reported there: apply must not quietly update two of three images.""" out = {} for image in IMAGES: path = map_path(image) if not os.path.lexists(path): continue # image not on this server try: with open(path, "rb") as f: raw = f.read() entries = json.loads(raw.decode("utf-8-sig")) except (OSError, ValueError) as ex: if errors is not None: errors.append(f"{image} HardwareDriver.json cannot be read ({str(ex)[:120]}); " "that image is not updated") continue if isinstance(entries, list): out[image] = {"path": path, "raw": raw, "entries": entries} elif errors is not None: errors.append(f"{image} HardwareDriver.json is not a list; that image is not updated") return out def is_win11(entry): ids = set(str(entry.get("osId") or "").replace(" ", "").split(",")) return bool(ids & WIN11_OS_IDS) def tokens(entry): return [t.strip() for t in str(entry.get("modelswminame") or "").split(",") if t.strip()] def entry_file(entry): return str(entry.get("fileName") or entry.get("FileName") or "") def entry_dir(entry): """destinationDir as a path relative to SHARED_DIR ("Out-of-box Drivers/...").""" d = str(entry.get("destinationDir") or entry.get("DestinationDir") or "") d = re.sub(r"(?i)^\*destinationdir\*\\?", "", d).replace("\\", "/").strip("/") if d.lower().startswith("deploy/"): d = d[len("deploy/"):] return d def models_txt_path(): return os.path.join(config.BIOS_DIR, "models.txt") def read_models_txt(): """[(index, raw line without CR/LF, substring, file, version)] for each manifest line, plus the raw text.""" try: with open(models_txt_path(), "rb") as f: text = f.read().decode("ascii", errors="replace") except OSError: return [], "" rows = [] for i, line in enumerate(text.split("\n")): line = line.rstrip("\r") if not line.strip() or line.lstrip().startswith("#") or line.count("|") < 2: continue sub, bfile, ver = (x.strip() for x in line.split("|")[:3]) if sub: rows.append((i, line, sub, bfile, ver)) return rows, text def resolve_dir_ci(root, relpath, create=False): """root/relpath matching each existing component case-insensitively (the share has Dell_11/Optiplex and an OptiPlex symlink to it). With create, missing components are made (owned like their parent). None when absent.""" cur = root for part in PurePosixPath(relpath).parts: try: names = os.listdir(cur) except OSError: names = [] hit = next((n for n in names if n == part), None) or \ next((n for n in names if n.lower() == part.lower()), None) if hit is None: if not create: return None new = os.path.join(cur, part) os.makedirs(new, exist_ok=True) _chown_like(new, cur) cur = new else: cur = os.path.join(cur, hit) return cur if os.path.isdir(cur) else None def _chown_like(path, ref): try: st = os.stat(ref) os.chown(path, st.st_uid, st.st_gid) except OSError: pass # not root (local tests): leave as is # --- Dell catalogs -------------------------------------------------------------- def _catalog_file(kind): return os.path.join(_dir("catalog"), os.path.basename(CATALOGS[kind])) def fetch_catalogs(): """Download both catalog cabs into the cache (they carry no hash of their own; the per-model cabs they list do).""" for kind, rel in CATALOGS.items(): dest = _catalog_file(kind) tmp = dest + ".part" if os.path.exists(tmp): os.remove(tmp) uc.download(DELL + rel, tmp, timeout=300) _cab_root(tmp) # refuse a cab that does not parse before replacing os.replace(tmp, dest) def driver_catalog(root): """Win11 driver packs: [{models, sids, ver, path, name, date, size, sha256, md5, release_id}], plus the catalog's own date.""" packs = [] for p in root.findall(NS + "DriverPackage"): oss = {o.get("osCode") for o in p.iter(NS + "OperatingSystem")} if "Windows11" not in oss: continue h = _hashes(p) path = p.get("path") or "" packs.append({ "models": [m.get("name") or "" for m in p.iter(NS + "Model")], "sids": sorted({(m.get("systemID") or "").upper() for m in p.iter(NS + "Model")} - {""}), "ver": p.get("dellVersion") or "", "path": path, "name": path.rsplit("/", 1)[-1], "date": (p.get("dateTime") or "")[:10], "size": _int(p.get("size")), "sha256": h.get("SHA256", ""), "md5": h.get("MD5", "") or (p.get("hashMD5") or ""), "release_id": p.get("releaseID") or "", }) return packs def catalog_index(root): """Per-model BIOS catalogs: [{brand, name, full, sid, path, sha256, size}].""" idx = [] for g in root.findall(NS + "GroupManifest"): b = g.find(f".//{NS}Brand/{NS}Display") brand = (b.text or "").strip() if b is not None else "" if _SKIP_BRANDS.search(brand): continue mi = g.find(NS + "ManifestInformation") if mi is None or not mi.get("path"): continue h = _hashes(mi) for m in g.iter(NS + "Model"): d = m.find(NS + "Display") name = (d.text or "").strip() if d is not None else "" idx.append({"brand": brand, "name": name, "full": norm(brand + " " + name), "sid": (m.get("systemID") or "").upper(), "path": mi.get("path"), "sha256": h.get("SHA256", ""), "size": _int(mi.get("size"))}) return idx class ModelCabs: """Latest BIOS per per-model cab, downloaded once and hash checked.""" def __init__(self, fetch=True): self.fetch = fetch self.cache = {} self.errors = {} def latest_bios(self, ix): path = ix["path"] if path in self.cache: return self.cache[path] best = None try: local = os.path.join(_dir("catalog", "models"), path.replace("/", "_")) fresh = os.path.exists(local) and ( not ix["sha256"] or uc.file_hash(local).lower() == ix["sha256"].lower()) if not fresh: if not self.fetch: raise RuntimeError("per-model catalog not cached") if os.path.exists(local): os.remove(local) uc.download(DELL + path, local, expect=ix["sha256"] or None, timeout=120) root = _cab_root(local) for c in root.iter(NS + "SoftwareComponent"): ct = c.find(NS + "ComponentType") if ct is None or ct.get("value") != "BIOS": continue cpath = c.get("path") or "" version = c.get("vendorVersion") or c.get("dellVersion") or "" h = _hashes(c) cand = { "file": cpath.rsplit("/", 1)[-1], "path": cpath, "url": DELL + cpath, "version": norm_version(version), "date": c.get("releaseDate") or "", "size": _int(c.get("size")), "sha256": h.get("SHA256", "").upper(), "md5": h.get("MD5", "").upper(), } if best is None or vtuple(cand["version"]) > vtuple(best["version"]): best = cand except Exception as ex: # one bad cab must not sink the whole scan self.errors[path] = str(ex)[:200] self.cache[path] = best return best # --- matching ------------------------------------------------------------------- def _rank(t_words, c_words): """How well a catalog model name fits a modelswminame token, or None. The model numbers (words with a digit) of the token must all be in the catalog name; then an exact word set beats one name containing the other ('OptiPlex Tower Plus 7020' vs 'OptiPlex 7020 Tower'), which beats a bare number match. Fewer extra words is better.""" t, c = set(t_words), set(c_words) digits = {w for w in t if any(ch.isdigit() for ch in w)} if not digits or not digits <= c: return None if t == c: return (3, 0) if t <= c or c <= t: return (2, -len(t ^ c)) if (t - digits) & c: return (1, -len(t ^ c)) return None def _pack_hits(token, packs): """Win11 packs whose model names best fit one modelswminame token.""" nn = norm(token).split() if not nn: return [] scored = [] for c in packs: ranks = [r for r in (_rank(nn, norm(x).split()) for x in c["models"]) if r] if ranks: scored.append((max(ranks), c)) if not scored: return [] top = max(r for r, _ in scored) return [c for r, c in scored if r == top] def _best(packs): return max(packs, key=lambda c: (aversion("_" + c["ver"]) or 0, c["date"])) if packs else None def _pack_view(c): return { "key": pack_code(c["name"], c["release_id"]) + "-" + _alnum(c["ver"]), "path": c["path"], "url": DELL + c["path"], "name": c["name"], "ver": c["ver"], "date": c["date"], "size": c["size"], "sha256": c["sha256"].upper(), "md5": c["md5"].upper(), "release_id": c["release_id"], "zip": zip_name(c["name"], c["release_id"], c["ver"]), "code": pack_code(c["name"], c["release_id"]), "models": sorted(set(c["models"])), } def scan_drivers(maps, packs): """One row per distinct current file in the Win11 entries of all maps.""" groups = {} for image, m in maps.items(): for e in m["entries"]: if not isinstance(e, dict) or not is_win11(e): continue fn = entry_file(e) if not fn: continue g = groups.setdefault(fn, {"tokens": [], "images": [], "dirs": []}) for t in tokens(e): if t not in g["tokens"]: g["tokens"].append(t) if image not in g["images"]: g["images"].append(image) d = entry_dir(e) if d not in g["dirs"]: g["dirs"].append(d) rows = [] for fn, g in groups.items(): per_token, codes = {}, set() for t in g["tokens"]: hits = _pack_hits(t, packs) codes |= {pack_code(c["name"], c["release_id"]) for c in hits} b = _best(hits) if b: per_token[t] = b best = _best(list(per_token.values())) ours = aversion(fn) warnings, notes = [], [] if not best: status = "no-match" elif ours is None: status = "unknown-version" elif (aversion("_" + best["ver"]) or 0) > ours: status = "update" else: status = "current" pack = _pack_view(best) if best else None if pack: if len(codes) > 1: warnings.append( f"The model names in this entry match {len(codes)} different Dell packs " f"({', '.join(sorted(codes))}); one zip cannot cover them all, so check " "which one fits or split the entry by hand.") if fn.lower().startswith("win10") or "_win10_" in fn.lower(): warnings.append("The current file is a Win10 pack, so its A-number is not comparable.") elif pack["code"] and pack["code"] not in _alnum(fn): warnings.append( f"The current file looks like a GE/MCL family pack (its name lacks " f"'{pack['code']}'), so its A-number may not be comparable. Decide by hand.") if "nvidia" in fn.lower(): notes.append("The current pack carries NVIDIA drivers; the update is skipped " "if the Dell pack has no NVIDIA INFs.") rows.append({ "id": "d-" + hashlib.sha1(fn.encode("utf-8")).hexdigest()[:10], "current": fn, "cur_ver": f"A{ours:02d}" if ours is not None else "", "tokens": g["tokens"], "images": g["images"], "dirs": g["dirs"], "status": status, "pack": pack, "warnings": warnings, "notes": notes, "default": status == "update" and not warnings, }) order = {"update": 0, "unknown-version": 1, "no-match": 2, "current": 3} rows.sort(key=lambda r: (order.get(r["status"], 9), ", ".join(r["tokens"]).lower())) return rows def _variants(ix): """The model names one index entry stands for, lower case, as check-bios would see them: 'Dell Pro 7 13 P713260/Dell Pro 7 13 2-in-1 P703260' is two machines sharing one per-model catalog.""" out = [] for v in ix["name"].split("/"): v = " ".join(v.split()).lower() if v: out.append(v) out.append((ix["brand"].lower() + " " + v).strip()) return out def _line_owner(lines, texts): """Index into lines of the first manifest line check-bios.cmd would pick for a model called any of texts (literal substring, case-insensitive).""" for n, (_, _, sub, _, _) in enumerate(lines): s = sub.lower() if any(s in t for t in texts): return n return None def _merge_by_family(cands): """One candidate per file family (stem), the highest version, models merged.""" fam = {} for c in cands: k = bios_stem(c["file"]) cur = fam.get(k) if cur is None or vtuple(c["version"]) > vtuple(cur["version"]): merged = dict(c, models=sorted(set(c["models"]) | set(cur["models"] if cur else []))) fam[k] = merged else: cur["models"] = sorted(set(cur["models"]) | set(c["models"])) return list(fam.values()) def scan_bios(lines, index, cabs): """One row per models.txt line. Each Dell model is credited to the first line that would catch it, as check-bios.cmd does, so '7090' does not report the models '7090 Ultra' (an earlier line) already owns. A line no Dell name contains literally (Dell lists 'OptiPlex Micro 7010' as just '7010') falls back to a model-number match that prefers our own file family.""" owned = {} for ix in index: for v in ix["name"].split("/") or [ix["name"]]: n = _line_owner(lines, [t for t in _variants(dict(ix, name=v))]) if n is not None and ix not in owned.setdefault(n, []): owned[n].append(ix) rows = [] for n, (i, raw, sub, bfile, ver) in enumerate(lines): loose = n not in owned models = owned.get(n, []) if loose: digits = {w for w in norm(sub).split() if any(ch.isdigit() for ch in w)} models = [ix for ix in index if digits and digits <= set(norm(ix["brand"] + " " + ix["name"]).split())] cands = [] for ix in models: b = cabs.latest_bios(ix) if b: cands.append(dict(b, models=[(ix["brand"] + " " + ix["name"]).strip()])) ours_stem = bios_stem(bfile) fam = _merge_by_family(cands) warnings, notes = [], [] if loose and fam: mine = [c for c in fam if bios_stem(c["file"]) == ours_stem] notes.append("No Dell model name contains this line; matched by model number only.") if mine: fam = mine fam.sort(key=lambda c: (bios_stem(c["file"]) != ours_stem, tuple(-x for x in vtuple(c["version"])))) if not fam: errs = [cabs.errors[ix["path"]] for ix in models if ix["path"] in cabs.errors] status = "error" if errs else "no-match" if errs: warnings.append("Per-model catalog failed: " + errs[0]) elif len(fam) > 1: status = "ambiguous" warnings.append(f"{len(fam)} different BIOS file families match this line; " "check-bios.cmd would flash the same file on all of them. Pick one.") else: c = fam[0] if bios_stem(c["file"]) != ours_stem: status = "different-file-family" warnings.append("Dell's latest BIOS for these models is a different file family.") elif vtuple(c["version"]) > vtuple(ver): status = "update" else: status = "current" rows.append({ "id": f"b-{i}", "line_no": i, "line": raw, "sub": sub, "current": bfile, "cur_ver": ver, "status": status, "candidates": fam, "pick": 0, "warnings": warnings, "notes": notes, "default": status == "update", }) order = {"update": 0, "different-file-family": 1, "ambiguous": 2, "error": 3, "no-match": 4, "current": 5} rows.sort(key=lambda r: (order.get(r["status"], 9), r["line_no"])) return rows def _entry_matches(entry, model): """PESetup's matcher (see services/hardware_coverage.py).""" try: from services import hardware_coverage return hardware_coverage._entry_matches(entry, model) except (ImportError, AttributeError): return any(t.lower() in model.lower() for t in str(entry.get("modelswminame") or "").split(",")) def scan_missing(maps, lines, packs, index, cabs): """Models WinPE has seen with no Win11 driver entry or no BIOS line, and the Dell pack/BIOS that would cover them. Informational only.""" state = uc.read_json(config.HARDWARE_STATE, {}) or {} out = [] for model, rec in sorted((state.get("models") or {}).items()): if any(s in model.lower() for s in _SKIP_MODELS): continue no_drv = [img for img, m in maps.items() if not any(isinstance(e, dict) and is_win11(e) and _entry_matches(e, model) for e in m["entries"])] no_bios = not any(sub.lower() in model.lower() for _, _, sub, _, _ in lines) if not no_drv and not no_bios: continue skus = [s.upper() for s in rec.get("skus") or [] if s] pack = bios = None if no_drv: hits = [c for c in packs if set(c["sids"]) & set(skus)] or _pack_hits(model, packs) b = _best(hits) pack = _pack_view(b) if b else None if no_bios: want = norm(model) ixs = [ix for ix in index if ix["sid"] in skus] or \ [ix for ix in index if want in {norm(v) for v in _variants(ix)}] for ix in ixs: bios = cabs.latest_bios(ix) if bios: break out.append({"model": model, "skus": skus, "no_drivers": no_drv, "no_bios": no_bios, "pack": pack, "bios": bios, "last_seen": rec.get("last_seen", "")}) return out # --- 1. check ------------------------------------------------------------------- def check_state(): return uc.read_json(_path("check.json"), {}) or {} def _check_progress(**kw): state = check_state() state.update(kw) uc.write_json(_path("check.json"), state) def last_scan(): return uc.read_json(_path("scan.json"), None) def check(fetch=True): """Fetch the catalogs (unless fetch is False: cached cabs only), compare everything, write scan.json and return it.""" uc.write_json(_path("check.json"), {"state": "running", "started": uc.now(), "step": "Fetching Dell catalogs"}) try: if fetch: fetch_catalogs() _check_progress(step="Reading catalogs") droot = _cab_root(_catalog_file("drivers")) iroot = _cab_root(_catalog_file("index")) packs = driver_catalog(droot) index = catalog_index(iroot) map_errors = [] maps = read_maps(map_errors) lines, _ = read_models_txt() _check_progress(step="Comparing driver packs") drivers = scan_drivers(maps, packs) cabs = ModelCabs(fetch=fetch) _check_progress(step="Reading per-model BIOS catalogs") bios = scan_bios(lines, index, cabs) missing = scan_missing(maps, lines, packs, index, cabs) result = { "checked": uc.now(), "catalogs": {"drivers": droot.get("dateTime") or droot.get("version") or "", "index": iroot.get("creationDateTime") or iroot.get("version") or ""}, "images": sorted(maps), "drivers": drivers, "bios": bios, "missing": missing, "cab_errors": cabs.errors, "map_errors": map_errors, } uc.write_json(_path("scan.json"), result) _check_progress(state="done", finished=uc.now(), step="") return result except Exception as ex: _check_progress(state="error", finished=uc.now(), error=str(ex)[:400]) raise # --- 2. download ---------------------------------------------------------------- def staged_state(): return uc.read_json(_path("staged.json"), {}) or {} def _save_staged(state): uc.write_json(_path("staged.json"), state) def _set_item(key, **kw): state = staged_state() state.setdefault("items", {}).setdefault(key, {}).update(kw) _save_staged(state) def select(driver_ids, bios_picks): """Turn checked rows of the last scan into a download list. driver_ids: scan row ids; bios_picks: {row id: candidate index}. Rows sharing one Dell file become one item. Returns the item count.""" scan = last_scan() if not scan: raise ValueError("no scan yet: run a check first") items = {} for row in scan["drivers"]: if row["id"] not in driver_ids or not row.get("pack"): continue p = row["pack"] if not file_name_ok(p["name"]) or not _CMD_SAFE.fullmatch(p["zip"]): raise ValueError(f"refusing Dell pack with an unsafe file name: {p['name']!r}") it = items.setdefault(p["key"], { "key": p["key"], "kind": "driver", "url": p["url"], "path": p["path"], "name": p["name"], "size": p["size"], "sha256": p["sha256"], "md5": p["md5"], "ver": p["ver"], "date": p["date"], "zip": p["zip"], "replaces": []}) if row["current"] not in it["replaces"]: it["replaces"].append(row["current"]) for row in scan["bios"]: if row["id"] not in bios_picks or not row["candidates"]: continue n = bios_picks[row["id"]] if not 0 <= n < len(row["candidates"]): n = 0 c = row["candidates"][n] if not bios_name_ok(c["file"], c["version"]): raise ValueError(f"refusing BIOS {c['file']!r} version {c['version']!r}: " "not safe in models.txt / check-bios.cmd") key = "bios-" + _alnum(c["file"]) it = items.setdefault(key, { "key": key, "kind": "bios", "url": c["url"], "path": c["path"], "name": c["file"], "size": c["size"], "sha256": c["sha256"], "md5": c["md5"], "ver": c["version"], "date": c["date"], "lines": []}) it["lines"].append({"line_no": row["line_no"], "line": row["line"], "sub": row["sub"], "current": row["current"]}) if not items: raise ValueError("nothing selected") uc.write_json(_path("selection.json"), {"created": uc.now(), "items": list(items.values())}) return len(items) def _free_bytes(path): try: return shutil.disk_usage(path).free except OSError: return 0 def _is_nvidia_inf(path, rel): """An NVIDIA INF: under a folder named NVIDIA..., an nv* INF whose text names NVIDIA, or any INF whose Provider line does (INFs are ANSI or UTF-16). A bare 'nv' prefix is not enough: nvme*.inf storage drivers would make a pack without NVIDIA look like it has some, and the NVIDIA rule would then replace a GE pack that carries them.""" if any(part.lower().startswith("nvidia") for part in rel.split(os.sep)[:-1]): return True try: with open(path, "rb") as f: data = f.read(1 << 20) except OSError: return False utf16 = data[:2] in (b"\xff\xfe", b"\xfe\xff") or data[1:200:2].count(0) > 50 text = data.decode("utf-16" if utf16 else "latin-1", errors="replace").lower() if os.path.basename(rel).lower().startswith("nv") and "nvidia" in text: return True return bool(re.search(r"^\s*provider\s*=[^\n]*nvidia", text, re.M)) def _count_infs(folder): infs = [p for p in glob.glob(os.path.join(folder, "**", "*"), recursive=True) if p.lower().endswith(".inf")] nv = [p for p in infs if _is_nvidia_inf(p, os.path.relpath(p, folder))] return len(infs), len(nv) def _stage_one(it): key = it["key"] if not file_name_ok(it["name"]) or not _CMD_SAFE.fullmatch(key): raise RuntimeError(f"unsafe file name {it['name']!r}; refusing it") folder = _dir("staged", key) exe = os.path.join(folder, it["name"]) algo, expect = ("sha256", it["sha256"]) if it.get("sha256") else ("md5", it.get("md5")) if not expect: raise RuntimeError("Dell lists no hash for this file; refusing it") need = it["size"] * (5 if it["kind"] == "driver" else 1) + (1 << 30) have = os.path.getsize(exe) if os.path.exists(exe) else 0 if _free_bytes(folder) + have < need: raise RuntimeError(f"not enough disk space in {folder} (need about {need // (1 << 30)} GB)") if not (have == it["size"] and uc.file_hash(exe, algo).lower() == expect.lower()): _set_item(key, status="downloading", partial=exe, error="") uc.download(it["url"], exe, expect=expect, algorithm=algo) _set_item(key, status="verified", partial="") if it["kind"] == "bios": _set_item(key, status="ready", file=exe, sha256_file=_sha256(exe), size_file=os.path.getsize(exe), finished=uc.now()) return # Driver pack: 7z-extract the exe and deflate-zip its top folder(s). work = os.path.join(folder, "x") shutil.rmtree(work, ignore_errors=True) os.makedirs(work) try: _set_item(key, status="extracting") r = subprocess.run(_nice() + ["7z", "x", "-y", "-o" + work, exe], stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) if r.returncode: raise RuntimeError(f"7z failed ({r.returncode}): {r.stderr.strip()[:200]}") tops = sorted(os.listdir(work)) infs, nvidia = _count_infs(work) if not tops or not infs: raise RuntimeError("the pack extracted to no INF files") _set_item(key, status="zipping", tops=tops, infs=infs, nvidia_infs=nvidia) final = os.path.join(folder, it["zip"]) tmp = os.path.join(folder, ".tmp-" + it["zip"]) if os.path.exists(tmp): os.remove(tmp) r = subprocess.run(_nice() + ["zip", "-r", "-q", "-X", tmp] + tops, cwd=work, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, text=True) if r.returncode: raise RuntimeError(f"zip failed ({r.returncode}): {r.stderr.strip()[:200]}") os.replace(tmp, final) finally: shutil.rmtree(work, ignore_errors=True) _set_item(key, status="ready", file=final, sha256_file=_sha256(final), size_file=os.path.getsize(final), finished=uc.now()) os.remove(exe) # the zip is what gets deployed; the exe can be fetched again def download(): """Background job: stage every item of selection.json. Resumable: ready items are skipped, a partial exe is resumed.""" sel = uc.read_json(_path("selection.json"), {}) or {} state = staged_state() state.update(state="running", started=uc.now(), finished="", error="") items = state.setdefault("items", {}) for it in sel.get("items", []): cur = items.get(it["key"], {}) if cur.get("status") == "ready" and os.path.exists(cur.get("file", "")): # Already staged: just widen what it replaces. for k in ("replaces", "lines"): if k in it: merged = cur.get(k, []) for v in it[k]: if v not in merged: merged.append(v) cur[k] = merged continue items[it["key"]] = dict(it, status="queued", error="", queued=uc.now()) _save_staged(state) failed = 0 for it in sel.get("items", []): if staged_state()["items"][it["key"]].get("status") == "ready": continue try: _stage_one(it) except Exception as ex: failed += 1 _set_item(it["key"], status="error", error=str(ex)[:300], partial="") state = staged_state() state.update(state="done", finished=uc.now(), error=f"{failed} item(s) failed" if failed else "") _save_staged(state) _audit("DELL_DOWNLOAD", f"{len(sel.get('items', []))} item(s), {failed} failed") def progress(): """staged.json with live percent for items still downloading, and whether the jobs are running.""" state = staged_state() running = uc.job_running(UNITS["download"]) if state.get("state") == "running" and not running: state["state"] = "interrupted" for it in (state.get("items") or {}).values(): pct = None if it.get("status") == "downloading" and it.get("partial") and it.get("size"): try: pct = min(100.0, round(100.0 * os.path.getsize(it["partial"]) / it["size"], 1)) except OSError: pct = 0.0 elif it.get("status") in ("verified", "extracting", "zipping", "ready", "applied"): pct = 100.0 it["percent"] = pct if not running and it.get("status") in ("queued", "downloading", "verified", "extracting", "zipping"): it["status"] = "interrupted" chk = check_state() if chk.get("state") == "running" and not uc.job_running(UNITS["check"]): chk["state"] = "interrupted" return {"download": state, "check": chk, "download_running": running, "check_running": uc.job_running(UNITS["check"])} def cancel_download(): uc.stop_job(UNITS["download"]) state = staged_state() for it in (state.get("items") or {}).values(): if it.get("status") in ("queued", "downloading", "verified", "extracting", "zipping"): it["status"] = "cancelled" if state.get("state") == "running": state["state"] = "cancelled" state["finished"] = uc.now() _save_staged(state) def discard(keys): """Drop staged items and their files (staging only; never the share).""" state = staged_state() items = state.get("items") or {} gone = [] for key in keys: if key not in items or "/" in key or key.startswith("."): continue shutil.rmtree(os.path.join(_dir("staged"), key), ignore_errors=True) items.pop(key) gone.append(key) _save_staged(state) return gone # --- 3. apply ------------------------------------------------------------------- def _ready(keys): items = (staged_state().get("items") or {}) return [items[k] for k in keys if k in items and items[k].get("status") == "ready" and os.path.exists(items[k].get("file", ""))] def _new_line(raw, bfile, ver): parts = raw.split("|") parts[1], parts[2] = bfile, ver return "|".join(parts) def preview(keys): """What apply(keys) would change, without changing anything.""" map_errors = [] maps = read_maps(map_errors) lines_by_no = {} _, text = read_models_txt() for n, line in enumerate(text.split("\n")): lines_by_no[n] = line.rstrip("\r") out = {"drivers": {img: [] for img in maps}, "bios": [], "skipped": [], "missing": [], "errors": map_errors} items = _ready(keys) out["missing"] = [k for k in keys if k not in {i["key"] for i in items}] for it in items: if it["kind"] == "driver": for cur in it.get("replaces", []): if "nvidia" in cur.lower() and not it.get("nvidia_infs"): out["skipped"].append(f"{cur}: has NVIDIA drivers, Dell pack {it['zip']} has none") continue hit = False for img, m in maps.items(): for e in m["entries"]: if isinstance(e, dict) and entry_file(e) == cur: hit = True out["drivers"][img].append({ "models": e.get("modelswminame", ""), "old": cur, "new": it["zip"], "dir": entry_dir(e)}) if not hit: out["skipped"].append(f"{cur}: no map entry uses it any more") else: if not bios_name_ok(it["name"], it["ver"]): out["errors"].append(f"{it['name']}: file name or version not safe for check-bios.cmd") continue for ln in it.get("lines", []): n = _find_line(lines_by_no, ln) if n is None: out["skipped"].append(f"models.txt line '{ln['line']}' changed since the check") continue out["bios"].append({"old": lines_by_no[n], "new": _new_line(lines_by_no[n], it["name"], it["ver"])}) return out def _find_line(lines_by_no, ln): """The line's current index: where the scan saw it, or its unique copy.""" if lines_by_no.get(ln["line_no"]) == ln["line"]: return ln["line_no"] hits = [n for n, t in lines_by_no.items() if t == ln["line"]] return hits[0] if len(hits) == 1 else None def _same_file(path, size, sha256): return os.path.getsize(path) == size and _sha256(path) == sha256.upper() def _place(src, dest_dir, name, size, sha256, move): """Put src into dest_dir/name. Rename when move and on one filesystem, else copy and verify. An existing file is accepted only if identical. Returns the placed path.""" dst = os.path.join(dest_dir, name) if os.path.exists(dst): if _same_file(dst, size, sha256): return dst raise RuntimeError(f"{dst} already exists with different content; not overwritten") if move: try: os.rename(src, dst) _chown_like(dst, dest_dir) return dst except OSError as ex: if ex.errno != 18: # EXDEV: other filesystem, copy instead raise fd, tmp = tempfile.mkstemp(dir=dest_dir, prefix=".tmp-") os.close(fd) try: shutil.copyfile(src, tmp) if not _same_file(tmp, size, sha256): raise RuntimeError(f"copy of {name} to {dest_dir} failed verification") os.chmod(tmp, 0o644) _chown_like(tmp, dest_dir) os.replace(tmp, dst) finally: if os.path.exists(tmp): os.remove(tmp) return dst def _write_like(path, data, ts, tag="pre-webupdate"): """Back up path to path.bak--, then atomically replace it with data (bytes), keeping owner and mode. Returns the backup path.""" real = os.path.realpath(path) backup = f"{path}.bak-{ts}-{tag}" n = 1 while os.path.lexists(backup): # never overwrite an earlier backup n += 1 backup = f"{path}.bak-{ts}-{tag}-{n}" shutil.copy2(real, backup) st = os.stat(real) folder = os.path.dirname(real) fd, tmp = tempfile.mkstemp(dir=folder, prefix=".tmp-") try: with os.fdopen(fd, "wb") as f: f.write(data) f.flush() os.fsync(f.fileno()) os.chmod(tmp, st.st_mode & 0o7777) try: os.chown(tmp, st.st_uid, st.st_gid) os.chown(backup, st.st_uid, st.st_gid) except OSError: pass os.replace(tmp, real) finally: if os.path.exists(tmp): os.remove(tmp) return backup def dump_map(entries, raw): """Serialise a map the way the originals are written: indent 4, UTF-8, a trailing newline only if the original had one, BOM only if it had one.""" text = json.dumps(entries, indent=4, ensure_ascii=False) if raw.endswith(b"\n"): text += "\n" data = text.encode("utf-8") return (b"\xef\xbb\xbf" + data) if raw.startswith(b"\xef\xbb\xbf") else data def history(): return uc.read_json(_path("applied.json"), []) or [] class _Busy: """Non-blocking exclusive lock so two applies/rollbacks (a double click, two tabs) never interleave their reads and rewrites of the same maps.""" def __enter__(self): self.fd = os.open(_path(".apply.lock"), os.O_CREAT | os.O_RDWR, 0o644) try: fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB) except OSError: os.close(self.fd) raise RuntimeError("another apply or rollback is already running; try again " "when it has finished") from None return self def __exit__(self, *exc): fcntl.flock(self.fd, fcntl.LOCK_UN) os.close(self.fd) return False def apply(keys): """Install the staged items named by keys. Returns a report dict.""" with _Busy(): return _apply(keys) def _apply(keys): ts = _stamp() report = {"id": ts, "at": uc.now(), "drivers": [], "bios": [], "skipped": [], "errors": [], "backups": {}, "after": {}} maps = read_maps(report["errors"]) changed_maps = set() items = _ready(list(dict.fromkeys(keys))) staged_items = {} # only the items this apply changed are written back # Driver zips first: a map only changes once its zip is in place. for it in [i for i in items if i["kind"] == "driver"]: todo = [] for cur in it.get("replaces", []): if "nvidia" in cur.lower() and not it.get("nvidia_infs"): report["skipped"].append(f"{cur}: has NVIDIA drivers, Dell pack {it['zip']} has none") continue hits = [(img, e) for img, m in maps.items() for e in m["entries"] if isinstance(e, dict) and entry_file(e) == cur] if not hits: report["skipped"].append(f"{cur}: no map entry uses it any more") continue todo.append((cur, hits)) if not todo: continue placed = [] try: if not _CMD_SAFE.fullmatch(it["zip"]): raise RuntimeError("unsafe zip name") dirs = [] for _, hits in todo: for _, e in hits: rel = entry_dir(e) if ".." in PurePosixPath(rel).parts: raise RuntimeError(f"destinationDir {rel} leaves the share") d = resolve_dir_ci(config.SHARED_DIR, rel, create=True) if d is None: raise RuntimeError(f"cannot resolve {rel}") if os.path.realpath(d) not in {os.path.realpath(x) for x in dirs}: dirs.append(d) src, move = it["file"], True for d in dirs: p = _place(src, d, it["zip"], it["size_file"], it["sha256_file"], move) placed.append(p) src, move = p, False # later directories get verified copies except Exception as ex: report["errors"].append(f"{it['zip']}: {ex}") if placed and not os.path.exists(it["file"]): # The staged zip was renamed onto the share before a later # folder failed: point the item there so a retry can finish. staged_items.setdefault(it["key"], {})["file"] = placed[0] continue for cur, hits in todo: for img, e in hits: for k in ("fileName", "FileName"): if k in e: e[k] = it["zip"] e["url"] = it["url"] e["hash"] = it["sha256_file"] e["size"] = it["size_file"] changed_maps.add(img) report["drivers"].append({"old": cur, "new": it["zip"], "placed": placed, "images": sorted({img for img, _ in hits})}) staged_items.setdefault(it["key"], {}).update(status="applied", applied=ts, placed=placed) # One write per real file: if an image's map is a symlink to another's, # writing it twice would back up the already-updated copy the second time. written = set() for img in sorted(changed_maps, key=lambda i: (os.path.islink(maps[i]["path"]), i)): m = maps[img] real = os.path.realpath(m["path"]) if real in written: continue written.add(real) data = dump_map(m["entries"], m["raw"]) try: report["backups"][m["path"]] = _write_like(m["path"], data, ts) report["after"][m["path"]] = hashlib.sha256(data).hexdigest() except OSError as ex: report["errors"].append(f"{img} HardwareDriver.json not written ({ex}); its zip is on " "the share but this image still uses the old file") # BIOS: only the file|version fields of its lines; the exe goes into # BIOS_DIR only when at least one line will point at it. bios_items = [i for i in items if i["kind"] == "bios"] if bios_items: mpath = models_txt_path() try: with open(mpath, "rb") as f: raw = f.read() text = raw.decode("ascii") except (OSError, UnicodeDecodeError) as ex: report["errors"].append(f"models.txt: {ex}") text = None if text is not None: split = text.split("\n") lines_by_no = {n: t.rstrip("\r") for n, t in enumerate(split)} edits, done = {}, [] for it in bios_items: if not bios_name_ok(it["name"], it["ver"]): report["errors"].append(f"{it['name']}: file name or version not safe for " "check-bios.cmd; not applied") continue mine = {} for ln in it.get("lines", []): n = _find_line(lines_by_no, ln) if n is None: report["skipped"].append(f"models.txt line '{ln['line']}' changed since the check") continue mine[n] = _new_line(lines_by_no[n], it["name"], it["ver"]) if not mine: continue try: placed = _place(it["file"], config.BIOS_DIR, it["name"], it["size_file"], it["sha256_file"], True) except Exception as ex: report["errors"].append(f"{it['name']}: {ex}") continue edits.update(mine) done.append((it, placed, mine)) if edits: for n, new in edits.items(): cr = "\r" if split[n].endswith("\r") else "" split[n] = new + cr data = "\n".join(split).encode("ascii") try: report["backups"][mpath] = _write_like(mpath, data, ts) report["after"][mpath] = hashlib.sha256(data).hexdigest() except OSError as ex: report["errors"].append(f"models.txt not written ({ex}); the BIOS files are in " "place but no line points at them yet") for it, placed, _ in done: # keep them applicable for a retry staged_items.setdefault(it["key"], {})["file"] = placed done = [] for it, placed, mine in done: for n, new in mine.items(): report["bios"].append({"old": lines_by_no[n], "new": new}) staged_items.setdefault(it["key"], {}).update(status="applied", applied=ts, placed=[placed]) # Merge field by field into a fresh read: a download job may have # written other items while this ran. state = staged_state() for k, v in staged_items.items(): if k in state.get("items", {}): state["items"][k].update(v) _save_staged(state) if report["drivers"] or report["bios"] or report["errors"]: hist = history() hist.insert(0, report) uc.write_json(_path("applied.json"), hist[:50]) _audit("DELL_APPLY", f"{len(report['drivers'])} driver replacement(s), " f"{len(report['bios'])} BIOS line(s), {len(report['skipped'])} skipped, " f"{len(report['errors'])} error(s)") _invalidate() return report def rollback(apply_id): """Restore the maps and models.txt saved before apply apply_id. A file changed again since is left alone and reported. Placed zips/exes stay.""" with _Busy(): return _rollback(apply_id) def _rollback(apply_id): rec = next((h for h in history() if h.get("id") == apply_id), None) if rec is None: raise ValueError("unknown apply") ts = _stamp() restored, refused = [], [] for path, backup in rec.get("backups", {}).items(): try: with open(path, "rb") as f: now_hash = hashlib.sha256(f.read()).hexdigest() if now_hash != rec.get("after", {}).get(path): refused.append(f"{path} changed since that apply") continue with open(backup, "rb") as f: data = f.read() _write_like(path, data, ts, tag="pre-rollback") restored.append(path) except OSError as ex: refused.append(f"{path}: {ex}") hist = history() for h in hist: if h.get("id") == apply_id: h["rolled_back"] = uc.now() if restored else h.get("rolled_back") uc.write_json(_path("applied.json"), hist) _audit("DELL_ROLLBACK", f"{apply_id}: restored {len(restored)}, refused {len(refused)}") _invalidate() return restored, refused def _audit(action, detail): try: from services.audit import audit audit(action, detail) except Exception: pass # audit log not writable (local tests): the history file still has it def _invalidate(): try: from services import hardware_coverage hardware_coverage.invalidate() except Exception: pass # --- hints for /hardware ---------------------------------------------------------- def update_hints(): """{"drivers": {current zip (lower): Dell version}, "bios": {current BIOS file (lower): Dell version}} for rows of the last scan with an update. Keyed by file so /hardware can match what each model actually resolves to (first matching map entry / models.txt line) without re-matching.""" scan = last_scan() or {} out = {"drivers": {}, "bios": {}} for r in scan.get("drivers", []): if r.get("status") == "update" and r.get("current"): out["drivers"][r["current"].lower()] = (r.get("pack") or {}).get("ver", "") for r in scan.get("bios", []): if r.get("status") == "update" and r.get("current"): cands = r.get("candidates") or [] pick = r.get("pick") or 0 ver = cands[pick].get("version", "") if 0 <= pick < len(cands) else "" out["bios"].setdefault(r["current"].lower(), ver) return out # --- job entry point -------------------------------------------------------------- def start(kind): """Start the check or download job (systemd-run). The two never overlap.""" other = "download" if kind == "check" else "check" if uc.job_running(UNITS[other]): raise RuntimeError(f"the {other} job is still running") if kind == "check": if uc.job_running(UNITS["check"]): raise RuntimeError("a check is already running") uc.write_json(_path("check.json"), {"state": "running", "started": uc.now(), "step": "Starting"}) try: uc.start_job(UNITS[kind], ["services.dell_updates", kind]) except Exception as ex: if kind == "check": # else the page would show a check that never ran _check_progress(state="error", finished=uc.now(), error=str(ex)[:400]) raise def main(argv): if not argv or argv[0] not in ("check", "download", "apply"): print("usage: python3 -m services.dell_updates check|download|apply KEY...", file=sys.stderr) return 2 if argv[0] == "check": check(fetch="--offline" not in argv) elif argv[0] == "download": download() else: print(json.dumps(apply(argv[1:]), indent=1)) return 0 if __name__ == "__main__": sys.exit(main(sys.argv[1:]))