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:
201
webapp/views/images.py
Normal file
201
webapp/views/images.py
Normal file
@@ -0,0 +1,201 @@
|
||||
"""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"))
|
||||
Reference in New Issue
Block a user