"""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))