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.
This commit is contained in:
cproudlock
2026-10-13 16:58:55 -04:00
parent ddf0a2fdf2
commit 397063f987
39 changed files with 15109 additions and 1106 deletions

View File

@@ -10,6 +10,10 @@ This file is the route surface; most logic lives in ``services/``:
services.deploy - import_deploy + merge_tree + symlink dance
services.unattend - parse + build + form-extract for unattend.xml
services.wim - boot.wim startnet.cmd extract/update via wimtools
services.image_pages - per-image page tabs, header + overview data
services.control_files - preview/backup/atomic write/undo of control files
The Updates pages are blueprints in ``views/`` (registered below).
"""
import hashlib
@@ -35,7 +39,6 @@ from flask import (
stream_with_context,
url_for,
)
from lxml import etree
from werkzeug.utils import secure_filename
import config
@@ -43,17 +46,29 @@ from services import (
blancco_evidence,
hardware_coverage,
blancco_report,
dell_updates,
deploy,
driver_map,
fs,
control_files,
image_pages,
image_registry,
images,
imaging_status,
mcl_ppkg,
system,
unattend,
wim,
)
from services.audit import audit
from services.csrf import init_csrf
from views import blancco_update as blancco_update_views
from views import bprt_sync as bprt_sync_views
from views import dell_updates as dell_updates_views
from views import images as images_views
from views import images_drivers as images_drivers_views
from views import images_unattend as images_unattend_views
from views import ipxe as ipxe_views
app = Flask(__name__)
app.secret_key = config.FLASK_SECRET_KEY
@@ -65,6 +80,21 @@ init_csrf(app)
# baked-in config.py defaults on first run). Mutates config.* in place.
image_registry.reload()
# Updates pages (Dell drivers/BIOS, iPXE, Blancco, BPRT sync): one blueprint
# each in views/, logic in services/; all routes live under /updates/.
app.register_blueprint(dell_updates_views.bp)
app.register_blueprint(ipxe_views.bp)
app.register_blueprint(blancco_update_views.bp)
app.register_blueprint(bprt_sync_views.bp)
# GE Aerospace image pages: one page per image with tabs (Overview, Drivers,
# Unattend, OS and Packages) under templates/_image_header.html. The tab ->
# endpoint table is services/image_pages.IMAGE_TABS; unattend_editor below
# is the Unattend tab, the others are blueprints.
app.register_blueprint(images_views.bp)
app.register_blueprint(images_drivers_views.bp)
app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
# ---------------------------------------------------------------------------
# Routes - pages
@@ -186,38 +216,15 @@ def unattend_editor(image_type):
xml_file = fs.unattend_path(image_type)
if request.method == "POST":
# Plain form post (the page itself saves through Review changes,
# views/images_unattend.py). Same path: the form is applied to the
# current text, backup, undo point, lint gate.
save_mode = request.form.get("save_mode", "form")
if save_mode == "raw":
raw_xml = request.form.get("raw_xml", "")
try:
etree.fromstring(raw_xml.encode("utf-8"))
except etree.XMLSyntaxError as exc:
flash(f"Invalid XML: {exc}", "danger")
data = unattend.parse_unattend(xml_file)
data["raw_xml"] = raw_xml
return render_template(
"unattend_editor.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
data=data,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
)
xml_content = raw_xml
body = {"mode": "raw", "raw_xml": request.form.get("raw_xml", "")}
else:
form_data = unattend.extract_form_data(request.form)
xml_content = unattend.build_unattend_xml(form_data)
try:
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
with open(xml_file, "w", encoding="utf-8") as fh:
fh.write(xml_content)
audit("UNATTEND_SAVE", f"{image_type} ({save_mode})")
flash("unattend.xml saved successfully.", "success")
except Exception as exc:
flash(f"Failed to save: {exc}", "danger")
body = {"mode": "form", "form": unattend.extract_form_data(request.form)}
images_unattend_views.save_and_flash(image_type, body, f"Unattend save ({save_mode}, form post)")
return redirect(url_for("unattend_editor", image_type=image_type))
data = unattend.parse_unattend(xml_file)
@@ -225,77 +232,72 @@ def unattend_editor(image_type):
"unattend_editor.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="unattend",
data=data,
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
**images_unattend_views.page_context(image_type),
)
@app.route("/images/<image_type>/config")
def image_config(image_type):
"""Old single Configuration page, now split into tabs. Bookmarks land on
the Overview, or on one tab with ?tab=drivers|os|unattend."""
if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
tab = request.args.get("tab", "overview")
if tab not in image_pages.TAB_IDS:
tab = "overview"
return redirect(image_pages.tab_url(image_type, tab))
cfg = images.load_image_config(image_type)
return render_template(
"image_config.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
config=cfg,
)
# Legacy /config/save sections and the tab each one now lives on.
_CONFIG_SECTIONS = {
"hardware_models": "os",
"drivers": "drivers",
"operating_systems": "os",
"packages": "os",
}
@app.route("/images/<image_type>/config/save", methods=["POST"])
def image_config_save(image_type):
"""Old one-section form save. No page posts here any more, and it must
not write: it would skip the diff preview, the stale-file check and the
Drivers tab's lint gate. It sends the user to the tab that saves now."""
if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
tab = _CONFIG_SECTIONS.get(request.form.get("section", ""), "overview")
flash("This form no longer saves. Make the change on this tab and use Review and save; "
"nothing was written.", "warning")
return redirect(image_pages.tab_url(image_type, tab))
section = request.form.get("section", "")
payload = request.form.get("payload", "[]")
try:
data = json.loads(payload)
except json.JSONDecodeError:
flash("Invalid JSON payload.", "danger")
return redirect(url_for("image_config", image_type=image_type))
ctrl = fs.control_path(image_type)
tools = fs.tools_path(image_type)
try:
if section == "hardware_models":
us_file = os.path.join(tools, "user_selections.json")
us_raw = fs.load_json(us_file)
us_data = us_raw[0] if us_raw and isinstance(us_raw, list) else {}
us_data["HardwareModelSelection"] = data
fs.save_json(us_file, [us_data])
audit("CONFIG_SAVE", f"{image_type}/hardware_models")
elif section == "drivers":
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
fs.save_json(os.path.join(ctrl, "HardwareDriver.json"), clean)
audit("CONFIG_SAVE", f"{image_type}/drivers")
elif section == "operating_systems":
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
fs.save_json(os.path.join(ctrl, "OperatingSystem.json"), clean)
audit("CONFIG_SAVE", f"{image_type}/operating_systems")
elif section == "packages":
clean = [{k: v for k, v in d.items() if not k.startswith("_")} for d in data]
fs.save_json(os.path.join(ctrl, "packages.json"), clean)
audit("CONFIG_SAVE", f"{image_type}/packages")
else:
flash(f"Unknown section: {section}", "danger")
return redirect(url_for("image_config", image_type=image_type))
flash(f"Saved {section.replace('_', ' ')} successfully.", "success")
except Exception as exc:
flash(f"Failed to save {section}: {exc}", "danger")
return redirect(url_for("image_config", image_type=image_type))
def _register_package(image_type, fname, dest):
"""Append {fileName, destinationDir} to packages.json through the same
path as the OS and Packages tab (format kept, .bak-<ts>-webui backup,
atomic, the tab's undo point). False when it is already listed."""
path = image_pages.control_file(image_type, "packages.json")
text = control_files.read_text(path)
entries = []
if text.strip():
try:
entries = json.loads(text.lstrip("\ufeff"))
except ValueError:
entries = None
if not isinstance(entries, list):
raise ValueError("packages.json is not a JSON list, so the file was not registered")
if any(str(e.get("fileName") or e.get("FileName") or "").lower() == fname.lower()
for e in entries if isinstance(e, dict)):
return False
entries.append({"fileName": fname, "destinationDir": dest})
plan = control_files.plan_json(path, entries, "packages.json")
control_files.commit(image_type, images_views.UNDO_SCOPE, [plan],
action=f"Package upload: {fname} registered")
return True
# ---------------------------------------------------------------------------
@@ -304,71 +306,65 @@ def image_config_save(image_type):
@app.route("/images/<image_type>/drivers/upload", methods=["POST"])
def images_drivers_upload(image_type):
"""Store a driver .zip under Out-of-box Drivers (services/driver_map.
save_upload: temp file + rename, in-use zips never overwritten). It is
NOT added to HardwareDriver.json here: the Drivers tab's Add to map does
that through the review step, with a backup and Undo. family and
destination_dir from old forms are ignored for that reason."""
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
back = redirect(image_pages.tab_url(image_type, "drivers"))
f = request.files.get("driver_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type))
family = (request.form.get("family") or "").strip()
dest = (request.form.get("destination_dir") or "").strip()
return back
overwrite = request.form.get("overwrite") == "1"
try:
rec = images.upload_driver(image_type, f, family=family,
destination_dir=dest, overwrite=overwrite)
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
msg = f"Uploaded {rec['filename']}."
msg += " Registered to family in HardwareDriver.json." if rec["registered"] else " Now in orphans list until adopted."
flash(msg, "success")
except (ValueError, FileExistsError, FileNotFoundError) as ex:
rec = driver_map.save_upload(image_type, f, overwrite=overwrite)
audit("DRIVER_UPLOAD", f"{image_type}/{rec['filename']} replaced={rec['replaced']}")
flash(f"Uploaded {rec['filename']}. It is listed under Unregistered zips; "
"use Add to map there to give it an entry.", "success")
except (ValueError, FileExistsError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
return back
@app.route("/images/<image_type>/drivers/adopt", methods=["POST"])
def images_drivers_adopt(image_type):
"""Old one-click adopt. It rewrote HardwareDriver.json with indent 2, no
backup and an entry with no modelswminame (PESetup throws on that), so
it now writes nothing and points at Add to map on the Drivers tab."""
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
filename = (request.form.get("filename") or "").strip()
family = (request.form.get("family") or "").strip()
dest = (request.form.get("destination_dir") or "").strip()
try:
rec = images.adopt_orphan(image_type, filename, family, dest)
audit("DRIVER_ADOPT", f"{image_type}/{rec['filename']} family={family}")
if rec.get("already_registered"):
flash(f"{rec['filename']} was already registered.", "info")
else:
flash(f"Adopted {rec['filename']} into HardwareDriver.json.", "success")
except (ValueError, FileNotFoundError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Adopt failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
flash("Use Add to map under Unregistered zips: the new entry needs model tokens and is "
"saved through Review and save. Nothing was written.", "info")
return redirect(image_pages.tab_url(image_type, "drivers"))
@app.route("/images/<image_type>/drivers/orphans/delete", methods=["POST"])
def images_drivers_orphans_delete(image_type):
"""Old orphan delete (form field "filename"), now the same safe delete
as the Drivers tab: zips any image, undo or rollback uses are kept."""
if image_type not in config.IMAGE_TYPES:
flash(f"Unknown image type: {image_type}", "danger")
return redirect(url_for("dashboard"))
# Filenames come as repeated form fields (one per checkbox).
back = redirect(image_pages.tab_url(image_type, "drivers"))
filenames = request.form.getlist("filename")
if not filenames:
flash("No files selected for removal.", "warning")
return redirect(url_for("image_config", image_type=image_type))
rec = images.remove_orphans(image_type, filenames)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} missing={len(rec['missing'])}")
parts = []
return back
rec = driver_map.delete_orphans(image_type, filenames)
audit("DRIVER_ORPHAN_REMOVE", f"{image_type} removed={len(rec['removed'])} "
f"refused={len(rec['refused'])} {', '.join(rec['removed'])}")
if rec["removed"]:
parts.append(f"Removed {len(rec['removed'])} orphan(s).")
if rec["missing"]:
parts.append(f"{len(rec['missing'])} not found / errored.")
flash(" ".join(parts) or "No-op.", "success" if rec["removed"] else "warning")
return redirect(url_for("image_config", image_type=image_type))
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
@app.route("/images/<image_type>/packages/upload", methods=["POST"])
@@ -379,20 +375,31 @@ def images_packages_upload(image_type):
f = request.files.get("package_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("image_config", image_type=image_type))
return redirect(image_pages.tab_url(image_type, "os"))
dest = (request.form.get("destination_dir") or "").strip()
overwrite = request.form.get("overwrite") == "1"
try:
rec = images.upload_package(image_type, f, destination_dir=dest, overwrite=overwrite)
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={rec['registered']}")
# The file only; packages.json is edited here through control_files
# (services.images.upload_package would rewrite it without a backup).
rec = images.upload_package(image_type, f, destination_dir="", overwrite=overwrite)
msg = f"Uploaded {rec['filename']}."
msg += " Registered in packages.json." if rec["registered"] else " Not registered (no destination_dir provided)."
registered = False
if dest:
try:
registered = _register_package(image_type, rec["filename"], dest)
msg += (" Registered in packages.json (backup made; Undo last save puts it back)."
if registered else " Already listed in packages.json.")
except (ValueError, OSError) as ex:
flash(f"Not registered: {ex}", "warning")
else:
msg += " Not registered (no destination_dir provided)."
audit("PACKAGE_UPLOAD", f"{image_type}/{rec['filename']} registered={registered}")
flash(msg, "success")
except (ValueError, FileExistsError) as ex:
flash(str(ex), "danger")
except Exception as ex:
flash(f"Upload failed: {ex}", "danger")
return redirect(url_for("image_config", image_type=image_type))
return redirect(image_pages.tab_url(image_type, "os"))
# ---------------------------------------------------------------------------
@@ -761,8 +768,13 @@ def hardware_seen():
@app.route("/hardware")
def hardware_page():
"""Every model WinPE has seen, with driver-pack and BIOS coverage."""
try:
# Files with a newer Dell pack/BIOS in the last Updates > Dell scan.
hints = dell_updates.update_hints()
except Exception:
hints = {"drivers": {}, "bios": {}}
return render_template("hardware.html", rows=hardware_coverage.coverage(),
images=hardware_coverage.IMAGES)
images=hardware_coverage.IMAGES, hints=hints)
@app.route("/hardware/dismiss", methods=["POST"])
@@ -1048,6 +1060,9 @@ def enrollment():
return render_template(
"enrollment.html",
packages=packages,
mcl_packages=mcl_ppkg.packages(),
mcl_archive=mcl_ppkg.archived(),
mcl_links=mcl_ppkg.image_links(),
active_ppkg=read_ppkg_conf(),
image_types=config.IMAGE_TYPES,
friendly_names=config.FRIENDLY_NAMES,
@@ -1069,6 +1084,13 @@ def enrollment_upload():
if not filename.lower().endswith(".ppkg"):
flash("Only .ppkg files are accepted.", "danger")
return redirect(url_for("enrollment"))
# An MCL name would also match the ppkg.conf regex below and become the
# shopfloor package; those go to BPRT through the MCL section instead.
# Check the sanitised name too: secure_filename turns spaces into
# underscores, which can make an MCL name out of one that was not.
if mcl_ppkg.is_mcl_name(f.filename) or mcl_ppkg.is_mcl_name(filename):
flash(f"{filename} is a standard/engineer MCL package; upload it in the MCL section.", "danger")
return redirect(url_for("enrollment") + "#mcl")
os.makedirs(config.ENROLLMENT_PPKG_DIR, exist_ok=True)
dest = os.path.join(config.ENROLLMENT_PPKG_DIR, filename)
@@ -1093,6 +1115,56 @@ def enrollment_upload():
return redirect(url_for("enrollment"))
@app.route("/enrollment/mcl/upload", methods=["POST"])
def mcl_ppkg_upload():
"""Standard/engineer MCL package: replaces the one with the same
tenant/environment/purpose, archiving the old (services.mcl_ppkg)."""
f = request.files.get("ppkg_file")
if not f or not f.filename:
flash("No file selected.", "danger")
return redirect(url_for("enrollment") + "#mcl")
try:
info, moved = mcl_ppkg.install_upload(f, f.filename)
except (ValueError, OSError) as exc:
flash(f"Upload rejected: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
name = info["filename"]
audit("MCL_PPKG_UPLOAD", f"{name} replaces {', '.join(moved) or 'nothing'}")
flash(f"Installed {name} for {info['prefix']}"
+ (f"; archived {', '.join(moved)}." if moved else "."), "success")
for warning in info["warnings"]:
flash(warning + ".", "warning")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/mcl/archive", methods=["POST"])
def mcl_ppkg_archive():
"""Move one package out of BPRT (clears a duplicate made by hand)."""
try:
name = mcl_ppkg.archive(request.form.get("filename", ""))
except (ValueError, OSError) as exc:
flash(f"Archive failed: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
audit("MCL_PPKG_ARCHIVE", name)
flash(f"Archived {name}.", "success")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/mcl/restore", methods=["POST"])
def mcl_ppkg_restore():
batch = request.form.get("batch", "")
name = request.form.get("filename", "")
try:
info, moved = mcl_ppkg.restore(batch, name)
except (ValueError, OSError) as exc:
flash(f"Restore failed: {exc}", "danger")
return redirect(url_for("enrollment") + "#mcl")
name = info["filename"]
audit("MCL_PPKG_RESTORE", f"{batch}/{name} replaces {', '.join(moved) or 'nothing'}")
flash(f"Restored {name}" + (f"; archived {', '.join(moved)}." if moved else "."), "success")
return redirect(url_for("enrollment") + "#mcl")
@app.route("/enrollment/download/<filename>")
def enrollment_download(filename):
filename = secure_filename(filename)
@@ -1492,31 +1564,24 @@ def api_save_unattend(image_type):
xml_file = fs.unattend_path(image_type)
payload = request.get_json(silent=True)
if not payload:
if not payload or not isinstance(payload, dict):
return jsonify({"error": "No JSON body provided"}), 400
# {"raw_xml": ...} writes the text as is; any other body is form data
# (keys left out stay as they are in the file). Backup, undo point and
# lint gate as on the Unattend tab.
if "raw_xml" in payload:
raw_xml = payload["raw_xml"]
try:
etree.fromstring(raw_xml.encode("utf-8"))
except etree.XMLSyntaxError as exc:
return jsonify({"error": f"Invalid XML: {exc}"}), 400
xml_content = raw_xml
body = {"mode": "raw", "raw_xml": payload["raw_xml"]}
else:
try:
xml_content = unattend.build_unattend_xml(payload)
except Exception as exc:
return jsonify({"error": f"Failed to build XML: {exc}"}), 400
body = {"mode": "form", "form": payload}
try:
os.makedirs(os.path.dirname(xml_file), exist_ok=True)
with open(xml_file, "w", encoding="utf-8") as fh:
fh.write(xml_content)
except Exception as exc:
return jsonify({"error": f"Failed to write file: {exc}"}), 500
plan = images_unattend_views.save(image_type, body, action="Unattend save (API)")
except images_unattend_views.Refused as exc:
return jsonify({"error": str(exc)}), exc.status
audit("UNATTEND_SAVE_API", image_type)
return jsonify({"status": "ok", "path": xml_file})
if plan["changed"]:
audit("UNATTEND_SAVE_API", image_type)
return jsonify({"status": "ok", "path": xml_file, "changed": plan["changed"]})
# ---------------------------------------------------------------------------
@@ -1559,11 +1624,24 @@ def timestamp_fmt(ts):
return local_dt(ts)
@app.template_filter("tojson_ordered")
def tojson_ordered(value):
"""JSON in the data's own key order (Flask's tojson sorts keys, which
turns a save of an unchanged row into a reordered-keys diff). Output is
a plain string, so it is HTML-escaped like any other value."""
return json.dumps(value, ensure_ascii=False)
@app.context_processor
def inject_globals():
return {
"all_image_types": config.IMAGE_TYPES,
"all_friendly_names": config.FRIENDLY_NAMES,
# image pages: tab table, header data, tab URLs (see _image_header.html)
"image_tabs": image_pages.IMAGE_TABS,
"image_header_info": image_pages.header_info,
"image_tab_url": image_pages.tab_url,
"image_tab_for_endpoint": image_pages.tab_for_endpoint,
"blancco_todo": dict(blancco_evidence.todo_counts(),
hardware=hardware_coverage.gap_count(),
total=blancco_evidence.todo_counts()["total"] + hardware_coverage.gap_count()),