Files
pxe-server/webapp/views/images_drivers.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

269 lines
11 KiB
Python

"""GE Aerospace image pages: the Drivers tab (Deploy/Control/HardwareDriver.json).
The page holds the whole driver map in the browser: search and filters,
move up/down (PESetup takes the FIRST matching entry, so order is policy),
edit an entry in a modal, remove, add an unregistered zip. Nothing is
written until Review and save:
POST /drivers/check the staged map -> zip status + lint findings per
row (services/driver_lint), nothing written
POST /drivers/preview per-entry changes with old vs new JSON, the whole
file diff, lint before/after; nothing written
POST /drivers/save the same body + the previewed hash; backup to
HardwareDriver.json.bak-<ts>-webui, atomic write in
the file's own format (services/control_files)
POST /drivers/undo put the last save back (only while the file still
holds what that save wrote)
POST /drivers/orphans/remove delete unregistered zips no image uses
Every JSON body carries "base", the map's sha256 when the page was loaded:
a map rewritten since (another tab, the Dell updater) is never overwritten
with a stale copy. Uploads stay on /drivers/upload in app.py.
The endpoint images_drivers.page is the "drivers" row of
services/image_pages.IMAGE_TABS; keep the name, or change that row.
"""
import json
import os
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from markupsafe import Markup
import config
from services import control_files, driver_lint, driver_map, hardware_coverage, image_pages
from services.audit import audit
bp = Blueprint("images_drivers", __name__, url_prefix="/images")
UNDO_SCOPE = "drivers"
def _unknown():
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
def _script_json(value):
"""JSON for a <script type="application/json"> block, key order kept
(Flask's tojson sorts keys). <, > and & are escaped so file content can
never close the script tag."""
text = json.dumps(value, ensure_ascii=False)
text = text.replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
return Markup(text)
def _folders(image_type):
"""Driver folders under Out-of-box Drivers, as destinationDir values,
for the edit modal's suggestions."""
root = driver_map._drivers_root(image_type)
out = []
if not root:
return out
for dirpath, dirnames, _files in os.walk(root):
dirnames.sort()
rel = os.path.relpath(dirpath, root)
if rel != ".":
out.append(driver_map.DEST_PREFIX + rel.replace(os.sep, "\\"))
return out
@bp.route("/<image_type>/drivers")
def page(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
entries, error = driver_map.load(image_type)
rows, _findings, counts = driver_map.rows(image_type, entries)
orphans = driver_map.orphans(image_type)
families = sorted({str(e.get("family")) for e in entries
if isinstance(e, dict) and e.get("family")}, key=str.lower)
hw_extra = image_pages.control_file(image_type, "hw_drivers.json")
data = {
"entries": entries,
"rows": rows,
"counts": counts,
"base": driver_map.file_sha(image_type),
"families": families,
"folders": _folders(image_type),
"urls": {
"check": url_for("images_drivers.check", image_type=image_type),
"preview": url_for("images_drivers.preview", image_type=image_type),
"save": url_for("images_drivers.save", image_type=image_type),
},
}
return render_template(
"image_drivers.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="drivers",
page_json=_script_json(data),
load_error=error,
counts=counts,
entry_count=len(entries),
orphans=orphans,
hw_drivers_extra=os.path.isfile(hw_extra),
last_save=control_files.last_save(image_type, UNDO_SCOPE),
)
# --- staged map: check, preview, save ------------------------------------------------------
class Stale(Exception):
"""The map changed on disk since the page was loaded."""
def _body():
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ValueError("expected a JSON object")
return body
def _staged(image_type, body, check_base=True):
"""(old entries, new entries, origs, moved flags) for a posted map."""
old, error = driver_map.load(image_type)
if error:
raise ValueError(error + ". Fix the file by hand first.")
if check_base and body.get("base") != driver_map.file_sha(image_type):
raise Stale("HardwareDriver.json changed on disk after this page was loaded "
"(another save, an undo or the Dell updater). Reload the page and "
"make the edits again.")
new, origs, moved = driver_map.staged(body, len(old))
return old, new, origs, moved
def _blocking(image_type, old, new, origs):
"""Lint of the map before and after; a save may not add CRITICAL
findings (each one breaks imaging for many or all PCs)."""
before_findings = driver_lint.lint_entries(old, image_type)
after_findings = driver_lint.lint_entries(new, image_type)
before = driver_lint.counts(before_findings)
after = driver_lint.counts(after_findings)
# Compare the CRITICAL findings themselves, not their count: fixing one
# while adding another keeps the count and must still be refused. Every
# CRITICAL check is about one entry, named here by its position in the
# file (origs), so an entry that already had the finding may still be
# edited or moved; a new entry or a new check on an old one is refused.
had = {(f["check"], f["index"]) for f in before_findings if f["level"] == driver_lint.CRITICAL}
new_critical = []
for f in after_findings:
if f["level"] != driver_lint.CRITICAL:
continue
i = f["index"]
o = origs[i] if i is not None and 0 <= i < len(origs) else None
if o is None or (f["check"], o) not in had:
new_critical.append(f["message"])
return before, after, after_findings, new_critical
def _json_error(exc):
if isinstance(exc, Stale):
return jsonify({"error": str(exc), "stale": True}), 409
return jsonify({"error": str(exc)}), 400
@bp.route("/<image_type>/drivers/check", methods=["POST"])
def check(image_type):
"""Row facts for the staged map (the page re-lints after every edit)."""
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
_old, new, _origs, _moved = _staged(image_type, _body(), check_base=False)
except ValueError as exc:
return _json_error(exc)
rows, _findings, counts = driver_map.rows(image_type, new)
return jsonify({"rows": rows, "counts": counts})
@bp.route("/<image_type>/drivers/preview", methods=["POST"])
def preview(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
old, new, origs, moved = _staged(image_type, _body())
except (ValueError, Stale) as exc:
return _json_error(exc)
plan = control_files.plan_json(driver_map.map_path(image_type), new, driver_map.FILE_NAME)
entry_changes, summary = driver_map.changes(old, new, origs, moved)
before, after, after_findings, new_critical = _blocking(image_type, old, new, origs)
return jsonify({
"files": [control_files.public(plan)],
"changes": entry_changes,
"summary": summary,
"lint": {"before": before, "after": after,
"after_errors": [f["message"] for f in after_findings
if f["level"] in (driver_lint.CRITICAL, driver_lint.ERROR)]},
"blocked": new_critical,
})
@bp.route("/<image_type>/drivers/save", methods=["POST"])
def save(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
body = _body()
old, new, origs, moved = _staged(image_type, body)
_before, _after, _f, new_critical = _blocking(image_type, old, new, origs)
if new_critical:
raise ValueError("refused: this map adds a CRITICAL finding: " + new_critical[0])
plan = control_files.plan_json(driver_map.map_path(image_type), new, driver_map.FILE_NAME)
_changes, summary = driver_map.changes(old, new, origs, moved)
expect = body.get("expect") if isinstance(body.get("expect"), dict) else {}
if driver_map.FILE_NAME not in expect:
raise ValueError("missing the previewed file hash; review the changes first")
changed = control_files.commit(image_type, UNDO_SCOPE, [plan],
action="Drivers save", expect=expect)
except (ValueError, Stale) as exc:
return _json_error(exc)
except control_files.Conflict as exc:
return jsonify({"error": str(exc)}), 409
except OSError as exc:
return jsonify({"error": f"write failed: {exc}"}), 500
if changed:
hardware_coverage.invalidate()
detail = ", ".join(f"{k} {v}" for k, v in summary.items() if v) or "whitespace only"
audit("IMAGE_DRIVERS_SAVE", f"{image_type} {driver_map.FILE_NAME}: {detail}")
flash(f"Saved {driver_map.FILE_NAME} ({detail}). The previous file is next to it "
"(.bak-...-webui) and Undo last save puts it back.", "success")
else:
flash("Nothing changed, nothing written.", "info")
return jsonify({"ok": True, "saved": bool(changed)})
@bp.route("/<image_type>/drivers/undo", methods=["POST"])
def undo(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
try:
rec = control_files.undo(image_type, UNDO_SCOPE)
hardware_coverage.invalidate()
audit("IMAGE_DRIVERS_UNDO", f"{image_type} {driver_map.FILE_NAME} (save of {rec.get('ts')})")
flash(f"Undid the last save: {driver_map.FILE_NAME} restored.", "success")
except control_files.Conflict as exc:
flash(f"Undo refused: {exc}", "danger")
except OSError as exc:
flash(f"Undo failed: {exc}", "danger")
return redirect(image_pages.tab_url(image_type, "drivers"))
# --- unregistered zips ---------------------------------------------------------------------
@bp.route("/<image_type>/drivers/orphans/remove", methods=["POST"])
def orphans_remove(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
rels = request.form.getlist("rel")
back = redirect(image_pages.tab_url(image_type, "drivers"))
if not rels:
flash("No files selected.", "warning")
return back
rec = driver_map.delete_orphans(image_type, rels)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
if rec["removed"]:
flash(f"Deleted {len(rec['removed'])} unregistered zip(s).", "success")
for rel, why in rec["refused"]:
flash(f"Not deleted: {rel} ({why}).", "warning")
return back