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.
202 lines
8.1 KiB
Python
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"))
|