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

202 lines
8.1 KiB
Python

"""GE Aerospace image pages: the Overview tab and the OS and Packages tab.
Every image page shares templates/_image_header.html (name, OS, tabs); the
tab table and URL helpers are in services/image_pages.py. The Drivers tab
lives in views/images_drivers.py, the Unattend tab is unattend_editor in
app.py.
OS and Packages edits three files with one Save: OperatingSystem.json,
packages.json (Deploy/Control) and the HardwareModelSelection list of
Tools/user_selections.json. The page posts the edited lists as JSON to
/os/preview (diff only, nothing written), then the same body plus the
previewed file hashes to /os/save. Writes go through services/control_files
(format kept, .bak-<ts>-webui backup, atomic, undo of the last save).
"""
import json
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
import config
from services import control_files, image_pages
from services.audit import audit
bp = Blueprint("images", __name__, url_prefix="/images")
UNDO_SCOPE = "os"
def _unknown():
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
@bp.route("/<image_type>")
def overview(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
return render_template(
"image_overview.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="overview",
ov=image_pages.overview(image_type),
)
@bp.route("/<image_type>/os")
def os_packages(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
sel, _ = image_pages.user_selections(image_type)
entries = image_pages.drivers(image_type)
# Per driver family: how many map entries carry it, and how many of
# those have their zip on disk (the old "On Disk" column, made exact).
fam = {}
for e in entries:
f = fam.setdefault(str(e.get("family") or ""), {"entries": 0, "on_disk": 0})
f["entries"] += 1
if image_pages.zip_path(image_type, e):
f["on_disk"] += 1
models = [dict(m=m, fam=fam.get(str(m.get("Id") or ""), {"entries": 0, "on_disk": 0}))
for m in (sel.get("HardwareModelSelection") or []) if isinstance(m, dict)]
oses = image_pages.operating_systems(image_type)
pkgs = image_pages.packages(image_type)
return render_template(
"image_os.html",
image_type=image_type,
friendly_name=config.FRIENDLY_NAMES.get(image_type, image_type),
active_tab="os",
oses=oses,
packages=pkgs,
models=models,
os_selection=sel.get("OperatingSystemSelection"),
has_selections=bool(sel),
last_save=control_files.last_save(image_type, UNDO_SCOPE),
)
# --- save path: preview, save, undo ---------------------------------------------------
def _clean_list(value, what):
if not isinstance(value, list) or not all(isinstance(x, dict) for x in value):
raise ValueError(f"{what}: expected a list of objects")
# Keys starting with _ are page-side annotations, never file content.
return [{k: v for k, v in x.items() if not str(k).startswith("_")} for x in value]
def _check_parses(path, name):
"""Refuse to save over a file that exists but is not a JSON list: the
page showed it as empty, so a save would replace content nobody saw
(user_selections.json would lose every key but the model list)."""
text = control_files.read_text(path)
if not text.strip():
return
try:
data = json.loads(text.lstrip("\ufeff"))
except ValueError:
data = None
if not isinstance(data, list) or (data and name == "user_selections.json"
and not isinstance(data[0], dict)):
raise ValueError(f"{name} on the share is not a JSON list this page can read "
"(bad JSON or not UTF-8); fix or restore it by hand first")
def _enabled_ok(value):
"""packages.json "enabled": the MCL files use 1/0, ours true/false;
either is kept as it is (PESetup's type must not change under it)."""
return isinstance(value, bool) or (isinstance(value, int) and value in (0, 1))
def _plans(image_type, body):
"""control_files plans for the sections present in the request body."""
plans = []
if "operating_systems" in body:
data = _clean_list(body["operating_systems"], "operating systems")
path = image_pages.control_file(image_type, "OperatingSystem.json")
_check_parses(path, "OperatingSystem.json")
plans.append(control_files.plan_json(path, data, "OperatingSystem.json"))
if "packages" in body:
data = _clean_list(body["packages"], "packages")
for p in data:
if "enabled" in p and not _enabled_ok(p["enabled"]):
raise ValueError("packages: enabled must be true/false or 1/0")
path = image_pages.control_file(image_type, "packages.json")
_check_parses(path, "packages.json")
plans.append(control_files.plan_json(path, data, "packages.json"))
if "hardware_models" in body:
models = _clean_list(body["hardware_models"], "hardware models")
for m in models:
m["Id"] = str(m.get("Id") or "").strip()
m["Model"] = str(m.get("Model") or "").strip()
if not m["Id"] or not m["Model"]:
raise ValueError("hardware models: every row needs a Model and a Driver Family ID")
_check_parses(image_pages.user_selections_path(image_type), "user_selections.json")
sel, raw = image_pages.user_selections(image_type)
first = dict(sel) # keeps key order; only the model list changes
first["HardwareModelSelection"] = models
new = [first] + list(raw[1:]) if raw else [first]
plans.append(control_files.plan_json(
image_pages.user_selections_path(image_type), new, "user_selections.json"))
return plans
def _body():
body = request.get_json(silent=True)
if not isinstance(body, dict):
raise ValueError("expected a JSON object")
return body
@bp.route("/<image_type>/os/preview", methods=["POST"])
def os_preview(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
plans = _plans(image_type, _body())
except ValueError as ex:
return jsonify({"error": str(ex)}), 400
files = [control_files.public(p) for p in plans]
return jsonify({"files": files, "changed": sum(1 for f in files if f["changed"])})
@bp.route("/<image_type>/os/save", methods=["POST"])
def os_save(image_type):
if image_type not in config.IMAGE_TYPES:
return jsonify({"error": "Unknown image type"}), 404
try:
body = _body()
plans = _plans(image_type, body)
expect = body.get("expect") if isinstance(body.get("expect"), dict) else {}
changed = control_files.commit(image_type, UNDO_SCOPE, plans,
action="OS and Packages save", expect=expect)
except ValueError as ex:
return jsonify({"error": str(ex)}), 400
except control_files.Conflict as ex:
return jsonify({"error": str(ex)}), 409
except OSError as ex:
return jsonify({"error": f"write failed: {ex}"}), 500
names = [p["name"] for p in changed]
if names:
audit("IMAGE_OS_SAVE", f"{image_type} {', '.join(names)}")
flash(f"Saved {', '.join(names)}. A backup of each is next to it (.bak-...-webui).", "success")
else:
flash("Nothing changed, nothing written.", "info")
return jsonify({"ok": True, "saved": names})
@bp.route("/<image_type>/os/undo", methods=["POST"])
def os_undo(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown()
try:
rec = control_files.undo(image_type, UNDO_SCOPE)
names = ", ".join(f["name"] for f in rec["files"])
audit("IMAGE_OS_UNDO", f"{image_type} {names} (save of {rec.get('ts')})")
flash(f"Undid the last save: {names} restored.", "success")
except control_files.Conflict as ex:
flash(f"Undo refused: {ex}", "danger")
except OSError as ex:
flash(f"Undo failed: {ex}", "danger")
return redirect(image_pages.tab_url(image_type, "os"))