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

37
webapp/views/__init__.py Normal file
View File

@@ -0,0 +1,37 @@
"""Feature blueprints registered by app.py (one module per page group).
views.dell_updates - Updates > Dell drivers and BIOS
views.ipxe - Updates > iPXE Secure Boot files
views.blancco_update - Updates > Blancco Drive Eraser releases
Shared here: the download speed cap form handler. The cap is one setting
(services/updates_common.py settings.json) used by every download; each
Updates page with downloads shows the same form and posts it to its own
route, which calls save_speed_cap() and redirects back to that page.
"""
import math
from flask import flash, request
from services import updates_common
from services.audit import audit
def save_speed_cap():
"""Validate and save the shared speed cap from the posted form, audit and
flash the result. Returns the saved settings, or None when refused."""
try:
value = float(request.form.get("max_mbps", ""))
if not math.isfinite(value): # float() accepts "nan"
raise ValueError("not a number")
data = updates_common.save_settings(value)
except ValueError as ex:
msg = str(ex) if "between" in str(ex) else "enter a number of MB/s (0 = no cap)"
flash(f"Speed cap not saved: {msg}", "danger")
return None
audit("UPDATES_SPEED_CAP", f"{data['max_mbps']} MB/s")
cap = data["max_mbps"]
flash(f"Download speed cap set to {cap:g} MB/s (shared by Dell and iPXE downloads)."
if cap else "Download speed cap removed (shared by Dell and iPXE downloads).", "success")
return data

View File

@@ -0,0 +1,214 @@
"""Updates > Blancco: install a new Blancco Drive Eraser release, test first.
Upload (or pick) the vendor ISO / USB image, stage it as a background job
into WEB_ROOT/blancco/staged/<version>/, boot it from a test entry in the
iPXE menu, then activate it (backup + atomic swap) or roll back. Staging is
a background job (systemd-run) whose progress JSON the page polls; the rest
are renames and small file writes and run inline. Logic lives in
services/blancco_update.py; this module is the route surface.
"""
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template, request,
send_file, url_for)
from services import blancco_update as bu
from services.audit import audit
bp = Blueprint("blancco_update", __name__, url_prefix="/updates/blancco")
def _back():
return redirect(url_for("blancco_update.page"))
@bp.route("/")
def page():
job = bu.job_state()
return render_template(
"updates_blancco.html",
current=bu.current(),
staged=bu.staged(),
backups=bu.backups(),
sources=bu.sources(),
job=job,
job_running=job.get("state") == "running",
test=bu.test_entry(),
initrd=bu.initrd_supports_base(),
upload_dir=bu.upload_dir(),
source_dirs=bu.source_dirs(),
server=bu.SERVER,
)
@bp.route("/status.json")
def status():
job = bu.job_state()
return jsonify({k: job.get(k) for k in ("state", "step", "percent", "message", "log",
"version", "source", "started", "finished")})
@bp.route("/upload", methods=["POST"])
def upload():
"""Raw body (the page's fetch upload, name in ?name=) streams straight to
disk; a plain multipart form works too."""
f = request.files.get("release_file")
try:
if f is not None:
name, size = bu.save_upload(f.stream, f.filename)
else:
name, size = bu.save_upload(request.stream, request.args.get("name", ""),
request.content_length)
except (ValueError, RuntimeError, OSError) as ex:
if f is None:
return jsonify({"ok": False, "error": str(ex)}), 400
flash(f"Upload failed: {ex}", "danger")
return _back()
audit("BLANCCO_UPLOAD", f"{name} ({size} bytes)")
if f is None:
return jsonify({"ok": True, "name": name, "size": size})
flash(f"Uploaded {name}.", "success")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
key, _, name = request.form.get("source", "").partition(":")
try:
path = bu.resolve_source(key, name)
bu.start_stage(path)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Staging not started: {ex}", "danger")
return _back()
audit("BLANCCO_STAGE", path)
flash(f"Staging {name}. This takes a minute or two; the page follows the job.", "info")
return _back()
@bp.route("/cancel", methods=["POST"])
def cancel():
bu.cancel_stage()
audit("BLANCCO_STAGE_CANCEL", "")
flash("Staging cancelled; partial files removed.", "warning")
return _back()
@bp.route("/prefs/<version>", methods=["POST"])
def merged_prefs(version):
f = request.files.get("prefs_file")
if not f or not f.filename:
flash("Choose the merged preferences.xml first.", "danger")
return _back()
try:
meta = bu.save_merged_prefs(version, f.read(4 << 20))
except bu.StageBlocked as ex:
flash(str(ex), "danger")
return _back()
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Preferences not accepted: {ex}", "danger")
return _back()
audit("BLANCCO_PREFS_MERGE", f"{version} preferences {meta.get('prefs_version')}")
flash(f"Merged preferences accepted; Blancco {version} is staged and ready to test. "
"Commit the same file to playbook/blancco-preferences.xml so a playbook run "
"does not put the old one back.", "success")
return _back()
@bp.route("/vendor-prefs/<version>.xml")
def vendor_prefs(version):
try:
path = bu.vendor_prefs_file(version)
except ValueError:
abort(404)
if not path:
abort(404)
return send_file(path, mimetype="application/xml", as_attachment=True,
download_name=f"vendor-preferences-{version}.xml")
@bp.route("/test/<version>", methods=["POST"])
def test_add(version):
try:
backup = bu.add_test_entry(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Test entry not added: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_ADD", f"{version} (menu backup {backup})")
flash(f"Boot menu now has \"Blancco Drive Eraser {version} (STAGED - TEST)\". "
"PXE boot a test machine and pick it.", "success")
return _back()
@bp.route("/test/remove", methods=["POST"])
def test_remove():
try:
backup = bu.remove_test_entry()
except (RuntimeError, OSError) as ex:
flash(f"Test entry not removed: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_REMOVE", f"menu backup {backup}" if backup else "none present")
flash("Test entry removed from the boot menu." if backup else "No test entry in the menu.", "success")
return _back()
@bp.route("/activate/<version>", methods=["POST"])
def activate(version):
try:
backup, files, removed = bu.activate(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not activated: {ex}", "danger")
return _back()
audit("BLANCCO_ACTIVATE", f"{version}; previous release saved in {backup}")
flash(f"Blancco {version} is live ({len(files)} files swapped). The previous release is in "
f"{backup}." + (" The test menu entry was removed." if removed is True else ""), "success")
if isinstance(removed, str):
flash(f"Blancco {version} is live, but {removed}.", "warning")
return _back()
@bp.route("/rollback/<name>", methods=["POST"])
def rollback(name):
try:
backup, files = bu.rollback(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not rolled back: {ex}", "danger")
return _back()
audit("BLANCCO_ROLLBACK", f"restored {name}; the replaced release saved in {backup}")
flash(f"Restored {name} ({len(files)} files); its folder is released now that the files are "
f"live. What was live before is saved in {backup}.", "success")
return _back()
@bp.route("/staged/<version>/delete", methods=["POST"])
def delete_staged(version):
try:
bu.delete_staged(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_STAGED", version)
flash(f"Deleted staged {version}.", "success")
return _back()
@bp.route("/backup/<name>/delete", methods=["POST"])
def delete_backup(name):
try:
bu.delete_backup(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_BACKUP", name)
flash(f"Deleted {name}.", "success")
return _back()
@bp.route("/uploads/<name>/delete", methods=["POST"])
def delete_upload(name):
try:
bu.delete_upload(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_UPLOAD", name)
flash(f"Deleted {name}.", "success")
return _back()

258
webapp/views/bprt_sync.py Normal file
View File

@@ -0,0 +1,258 @@
"""Updates > BPRT: bring the vendor bulk-enroll folder over from the work PC.
The work PC runs scripts/windows/Sync-BPRT.ps1, which reads manifest.json
here, uploads the new and changed files over SSH into BPRT-incoming/<batch>/
and points the user at this page. Here a batch is validated, its diff shown,
and activated (a new BPRT built from hard links and swapped in by rename);
any earlier BPRT can be rolled back to. Hashing, validation, unpacking and
activation run as one background job at a time; logic lives in
services/bprt_sync.py, this module is the route surface.
"""
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template,
request, send_file, url_for)
from services import bprt_sync as bs
from services import mcl_ppkg
from services.audit import audit
bp = Blueprint("bprt_sync", __name__, url_prefix="/updates/bprt")
def _back(batch=None):
if batch:
return redirect(url_for("bprt_sync.batch", name=batch))
return redirect(url_for("bprt_sync.page"))
def _recover():
try:
done = bs.recover()
except OSError as ex:
flash(f"BPRT is missing and could not be put back automatically: {ex}", "danger")
return
if done:
flash(f"Recovered: {done}.", "warning")
@bp.route("/")
def page():
_recover()
bs.ensure_incoming()
return render_template(
"updates_bprt.html",
live=bs.live_summary(),
packages=mcl_ppkg.packages(),
links=mcl_ppkg.image_links(),
job=bs.job_state(),
batches=bs.batches(),
zips=bs.uploaded_zips(),
archives=bs.archives(),
history=bs.history()[:20],
imaging=bs.active_imaging(),
script_available=bool(bs.sync_script()),
overlay=bs.OVERLAY,
renamed=bs.VENDOR_RENAMED,
incoming=bs.incoming_root(),
detail=None,
)
@bp.route("/batch/<name>")
def batch(name):
_recover()
try:
bs.check_batch(name)
except ValueError:
abort(404)
row = next((b for b in bs.batches() if b["name"] == name), None)
if row is None:
flash(f"There is no incoming batch {name} (activated batches are removed from the list).", "warning")
return _back()
return render_template(
"updates_bprt.html",
live=bs.live_summary(),
job=bs.job_state(),
imaging=bs.active_imaging(),
detail=row,
res=row["validation"],
incoming=bs.incoming_root(),
overlay=bs.OVERLAY,
renamed=bs.VENDOR_RENAMED,
)
@bp.route("/manifest.json")
def manifest():
"""What Sync-BPRT.ps1 compares against. While the live folder is being
hashed (the first time takes minutes) or swapped: 503 + Retry-After."""
bs.ensure_incoming() # Sync-BPRT.ps1 uploads into it as the SSH user
job = bs.job_state()
if job.get("running") and job.get("kind") == "activate":
return _busy("BPRT is being replaced right now; try again in a minute.", job, 60)
try:
data = bs.manifest()
except RuntimeError as ex:
return jsonify({"state": "error", "error": str(ex)}), 500
if data is not None:
data["finish_url"] = url_for("bprt_sync.page", _external=True)
return jsonify(data)
if not job.get("running"):
try:
bs.start("manifest")
except Exception as ex:
return _busy(f"the server could not start hashing BPRT: {ex}", bs.job_state(), 60)
job = bs.job_state()
audit("BPRT_MANIFEST_BUILD", "hashing the live BPRT for manifest.json")
if job.get("kind") == "manifest":
msg = "The server is hashing its BPRT folder (only the first time reads all of it)."
else:
msg = f"The server is busy with a BPRT {job.get('kind')} job."
return _busy(msg, job, 30)
def _busy(message, job, retry):
resp = jsonify({"state": "building", "message": message, "job": job.get("kind", ""),
"percent": job.get("percent"), "step": job.get("step", "")})
resp.status_code = 503
resp.headers["Retry-After"] = str(retry)
return resp
@bp.route("/status.json")
def status():
job = bs.job_state()
return jsonify({k: job.get(k) for k in ("kind", "arg", "state", "running", "step", "current",
"percent", "done_bytes", "total_bytes", "error", "result")})
@bp.route("/Sync-BPRT.ps1")
def script():
path = bs.sync_script()
if not path:
abort(404)
return send_file(path, mimetype="text/plain", as_attachment=True, download_name="Sync-BPRT.ps1")
def _start(kind, arg, ok_msg, audit_action):
try:
bs.start(kind, arg)
except (ValueError, RuntimeError) as ex:
flash(f"Not started: {ex}", "danger")
return False
audit(audit_action, arg)
flash(ok_msg, "info")
return True
@bp.route("/manifest/rebuild", methods=["POST"])
def rebuild():
_start("manifest", "", "Hashing the live BPRT; unchanged files are taken from the cache.",
"BPRT_MANIFEST_BUILD")
return _back()
@bp.route("/batch/<name>/validate", methods=["POST"])
def validate(name):
_start("validate", name, f"Validating {name}: every uploaded file is hashed, so a batch with "
"an Office zip takes a minute or two.", "BPRT_VALIDATE")
return _back(name)
@bp.route("/batch/<name>/activate", methods=["POST"])
def activate(name):
if request.form.get("confirm") != name:
flash("Activation not confirmed.", "danger")
return _back(name)
if _start("activate", name, f"Activating {name}. The page follows the job; the old BPRT is "
"archived, not deleted.", "BPRT_ACTIVATE_START"):
return _back()
return _back(name)
@bp.route("/batch/<name>/delete", methods=["POST"])
def delete(name):
try:
bs.delete_batch(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_BATCH_DELETE", name)
flash(f"Deleted incoming batch {name}. The live BPRT is not affected.", "success")
return _back()
@bp.route("/upload", methods=["POST"])
def upload():
"""Zip fallback: the raw body (the page's XHR upload, name in ?name=)
streams straight to disk next to BPRT; then the unzip job unpacks it
into a new batch and validates it."""
f = request.files.get("zip_file")
try:
if f is not None:
name, size = bs.save_zip(f.stream, f.filename)
else:
name, size = bs.save_zip(request.stream, request.args.get("name", ""),
request.content_length)
except (ValueError, RuntimeError, OSError) as ex:
if f is None:
return jsonify({"ok": False, "error": str(ex)}), 400
flash(f"Upload failed: {ex}", "danger")
return _back()
audit("BPRT_ZIP_UPLOAD", f"{name} ({size} bytes)")
started, error = True, ""
try:
bs.start("unzip", name)
except (ValueError, RuntimeError) as ex:
started, error = False, str(ex)
if f is None:
return jsonify({"ok": True, "name": name, "size": size, "started": started, "error": error})
if started:
flash(f"Uploaded {name}; unpacking and validating it now.", "info")
else:
flash(f"Uploaded {name}, but unpacking did not start: {error}", "warning")
return _back()
@bp.route("/zip/<name>/unzip", methods=["POST"])
def unzip(name):
_start("unzip", name, f"Unpacking {name} into a new batch, then validating it.", "BPRT_UNZIP_START")
return _back()
@bp.route("/zip/<name>/delete", methods=["POST"])
def delete_zip(name):
try:
bs.delete_zip(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_ZIP_DELETE", name)
flash(f"Deleted {name}.", "success")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
name = request.form.get("archive", "")
try:
entry = bs.rollback(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Rollback refused: {ex}", "danger")
return _back()
audit("BPRT_ROLLBACK", f"{name} (previous BPRT in {entry['archive']})")
flash(f"{name} is the live BPRT again; the one it replaced is archived as {entry['archive']}.", "success")
return _back()
@bp.route("/archive/delete", methods=["POST"])
def delete_archive():
name = request.form.get("archive", "")
try:
bs.delete_archive(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BPRT_ARCHIVE_DELETE", name)
flash(f"Deleted archive {name}. Files it shared with the live BPRT are still there.", "success")
return _back()

View File

@@ -0,0 +1,179 @@
"""Updates > Dell: Win11 driver packs and WinPE BIOS files from Dell's catalogs.
Check and download run as background jobs (systemd-run) that write progress
JSON the page polls; preview, apply, discard and rollback are quick renames
and small file rewrites, so they run inline. Logic lives in
services/dell_updates.py; this module is the route surface.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from services import dell_updates, updates_common
from services.audit import audit
from views import save_speed_cap
bp = Blueprint("dell_updates", __name__, url_prefix="/updates/dell")
def _back():
return redirect(url_for("dell_updates.page"))
def _render(preview=None, preview_keys=()):
prog = dell_updates.progress()
items = sorted((prog["download"].get("items") or {}).values(),
key=lambda i: (i.get("status") == "applied", i.get("kind", ""), i.get("name", "")))
return render_template(
"updates_dell.html",
scan=dell_updates.last_scan(),
prog=prog,
staged=items,
history=dell_updates.history()[:15],
settings=updates_common.settings(),
preview=preview,
preview_keys=list(preview_keys),
)
@bp.route("/")
def page():
return _render()
@bp.route("/status.json")
def status():
prog = dell_updates.progress()
items = {k: {"status": v.get("status"), "percent": v.get("percent"), "error": v.get("error", "")}
for k, v in (prog["download"].get("items") or {}).items()}
return jsonify({
"check": {"state": prog["check"].get("state", ""), "step": prog["check"].get("step", ""),
"error": prog["check"].get("error", "")},
"download": {"state": prog["download"].get("state", ""), "items": items},
"check_running": prog["check_running"],
"download_running": prog["download_running"],
})
@bp.route("/check", methods=["POST"])
def check():
try:
dell_updates.start("check")
except Exception as ex:
flash(f"Check not started: {ex}", "danger")
return _back()
audit("DELL_CHECK", "catalog check started")
flash("Checking Dell's catalogs; this page updates when the scan is ready (a minute or two).", "info")
return _back()
@bp.route("/download", methods=["POST"])
def download():
drivers = set(request.form.getlist("drv"))
picks = {}
for row_id in request.form.getlist("bios"):
try:
picks[row_id] = int(request.form.get(f"pick_{row_id}", "0"))
except ValueError:
picks[row_id] = 0
try:
count = dell_updates.select(drivers, picks)
dell_updates.start("download")
except Exception as ex:
flash(f"Download not started: {ex}", "danger")
return _back()
audit("DELL_DOWNLOAD_START", f"{count} item(s): {len(drivers)} driver row(s), {len(picks)} BIOS line(s)")
cap = updates_common.settings().get("max_mbps") or 0
flash(f"Downloading {count} item(s) at {'%g MB/s' % cap if cap else 'full speed'}. "
"Driver packs are hash checked and repacked here; nothing on the share changes until you apply.", "info")
return _back()
@bp.route("/cancel", methods=["POST"])
def cancel():
job = request.form.get("job", "download")
if job == "check":
updates_common.stop_job(dell_updates.UNITS["check"])
audit("DELL_CHECK_CANCEL", "")
flash("Check stopped. The previous scan, if any, is still shown.", "warning")
else:
dell_updates.cancel_download()
audit("DELL_DOWNLOAD_CANCEL", "")
flash("Download stopped. Partial files are kept; downloading the same items again resumes them.", "warning")
return _back()
@bp.route("/preview", methods=["POST"])
def preview():
keys = request.form.getlist("key")
if not keys:
flash("Select at least one staged item to apply.", "warning")
return _back()
return _render(preview=dell_updates.preview(keys), preview_keys=keys)
@bp.route("/apply", methods=["POST"])
def apply():
keys = request.form.getlist("key")
if not keys:
flash("Nothing selected to apply.", "warning")
return _back()
if updates_common.job_running(dell_updates.UNITS["download"]):
flash("A download is still running; apply when it has finished.", "danger")
return _back()
try:
report = dell_updates.apply(keys)
except Exception as ex:
flash(f"Apply failed: {ex}", "danger")
return _back()
parts = []
if report["drivers"]:
parts.append(f"{len(report['drivers'])} driver pack(s) replaced in the maps")
if report["bios"]:
parts.append(f"{len(report['bios'])} models.txt line(s) updated")
if parts:
flash("Applied: " + ", ".join(parts) + ". Old zips and BIOS files were kept; the maps and "
"models.txt were backed up first (Rollback in the history below).", "success")
for msg in report["skipped"]:
flash(f"Skipped {msg}.", "warning")
for msg in report["errors"]:
flash(f"Not applied: {msg}", "danger")
if not parts and not report["skipped"] and not report["errors"]:
flash("Nothing was applied (no selected item was ready).", "warning")
return _back()
@bp.route("/discard", methods=["POST"])
def discard():
keys = request.form.getlist("key")
if updates_common.job_running(dell_updates.UNITS["download"]):
flash("A download is running; cancel it first.", "danger")
return _back()
gone = dell_updates.discard(keys)
if gone:
audit("DELL_DISCARD", ", ".join(gone))
flash(f"Removed {len(gone)} staged item(s) from the staging area.", "success")
else:
flash("Nothing removed.", "warning")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
apply_id = request.form.get("apply_id", "")
try:
restored, refused = dell_updates.rollback(apply_id)
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()
if restored:
flash(f"Restored {len(restored)} file(s) from before apply {apply_id}. The new zips/BIOS files stay "
"on the share, unused.", "success")
for msg in refused:
flash(f"Not restored: {msg}.", "danger")
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
return _back()

201
webapp/views/images.py Normal file
View 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"))

View File

@@ -0,0 +1,268 @@
"""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

View File

@@ -0,0 +1,219 @@
"""GE Aerospace image pages: the Unattend tab's review, save and undo.
The page itself is unattend_editor in app.py (the "unattend" row of
services/image_pages.IMAGE_TABS, URL /images/<t>/unattend); it renders
templates/unattend_editor.html with page_context() from here. Saving is a
two-step JSON flow driven by static/unattend-editor.js:
POST /images/<t>/unattend/preview {mode, form | raw_xml, base_sha}
-> {files: [control_files.public(plan)], lint, xml}; nothing written
POST /images/<t>/unattend/save same body + expect {name: sha}
-> {ok: true} and a flash; the page reloads
POST /images/<t>/unattend/undo (form post) puts the last save back
mode "form" writes the form's values INTO the current file text
(services/unattend.apply_form: comments, *arch*, attribute layout and every
untouched line stay as they are); mode "raw" writes the Raw XML tab as is.
Writes go through services/control_files (FlatUnattendW10.xml.bak-<ts>-webui
next to the file, atomic, owner and mode kept, undo of the last save, scope
"unattend"). A save that adds a lint ERROR (scripts/lint-unattend.py
checks: one over-long command invalidates the whole answer file for its
pass) is refused.
save() is also used by the old form post in unattend_editor and by
/api/images/<t>/unattend, so every way of writing this file gets the same
backup, undo point and lint gate.
"""
import glob
import os
import threading
from flask import Blueprint, flash, jsonify, redirect, request, url_for
from lxml import etree
import config
from services import control_files, fs, image_pages, unattend
from services.audit import audit
bp = Blueprint("images_unattend", __name__, url_prefix="/images")
UNDO_SCOPE = "unattend"
FILE_NAME = "FlatUnattendW10.xml"
# One write at a time (the app serves requests on threads): the stale check,
# backup, write and undo record of one save must not interleave with another
# save or an undo, or the second would silently overwrite the first.
_write_lock = threading.Lock()
class Refused(Exception):
"""A save or preview that cannot go ahead; carries the HTTP status."""
def __init__(self, message, status=400):
super().__init__(message)
self.status = status
def page_context(image_type):
"""Template values for the Unattend tab besides the parsed data."""
path = fs.unattend_path(image_type)
exists = os.path.isfile(path)
text = control_files.read_text(path) if exists else ""
st = os.stat(path) if exists else None
preferred = image_pages.preferred_unattend_path(image_type)
return {
"file_exists": exists,
"file_sha": control_files.file_sha256(path),
"file_mtime": st.st_mtime if st else None,
"file_size": st.st_size if st else None,
"backup_count": len(glob.glob(glob.escape(path) + ".bak-*")) if exists else 0,
"lint": unattend.lint_text(text) if exists else [],
"last_save": control_files.last_save(image_type, UNDO_SCOPE),
"unattend_override": (os.path.relpath(preferred, fs.image_root(image_type))
if os.path.isfile(preferred) else None),
"limits": {"path": unattend.MAX_PATH, "commandline": unattend.MAX_COMMANDLINE,
"description": unattend.MAX_DESCRIPTION},
}
def _new_text(current, body):
"""The text body asks to write, from the form or the Raw XML tab."""
mode = body.get("mode")
if mode == "raw":
raw = body.get("raw_xml")
if not isinstance(raw, str):
raise Refused("raw_xml must be text")
try:
etree.fromstring(raw.lstrip("\ufeff").encode("utf-8"))
except etree.XMLSyntaxError as exc:
raise Refused(f"Invalid XML: {exc}")
# Keep the file's own line endings and BOM: a textarea hands back
# LF (a plain form post sends CRLF), and a browser may drop a
# leading BOM. The diff (and the file) then only show what was edited.
bom = raw.startswith("\ufeff")
raw = raw.lstrip("\ufeff").replace("\r\n", "\n")
if "\r\n" in current:
raw = raw.replace("\n", "\r\n")
if bom or current.startswith("\ufeff"):
raw = "\ufeff" + raw
return raw
if mode == "form":
form = body.get("form")
if not isinstance(form, dict):
raise Refused("form must be an object")
if "\ufffd" in current:
# read_text replaced bytes that are not UTF-8; writing the form
# into that text would change those bytes on lines nobody edited.
raise Refused(f"{FILE_NAME} holds bytes that are not UTF-8, so the form cannot "
"edit it without changing them. Fix the file in Raw XML first.")
try:
return unattend.apply_form(current, form)
except unattend.ApplyError as exc:
raise Refused(str(exc))
raise Refused("mode must be form or raw")
def plan(image_type, body):
"""(control_files plan, lint comparison) for a request body."""
if not isinstance(body, dict):
raise Refused("expected a JSON object")
path = fs.unattend_path(image_type)
base = body.get("base_sha")
if base and base != control_files.file_sha256(path):
raise Refused(f"{FILE_NAME} changed on disk since this page was opened. "
"Reload the page (your edits here will be lost) and make them again.", 409)
current = control_files.read_text(path)
new = _new_text(current, body)
return control_files.plan_text(path, new, FILE_NAME), unattend.lint_compare(current, new)
def save(image_type, body, action):
"""Plan, lint-gate, back up, write and record the undo point. Returns
the plan (plan["changed"] False when there was nothing to write)."""
with _write_lock:
p, lint = plan(image_type, body)
if p["changed"] and lint["new_errors"]:
msgs = "; ".join(f["message"] for f in lint["findings"] if f["level"] == "ERROR" and f["new"])
raise Refused(f"Not saved: this change would break the answer file. {msgs}")
expect = body.get("expect") if isinstance(body.get("expect"), dict) else None
if not expect:
# Callers without a preview (the API, the old form post): the
# file must still be what this plan was built from when it is
# written (commit checks it under its own lock).
expect = {p["name"]: p["sha_before"]}
try:
control_files.commit(image_type, UNDO_SCOPE, [p], action=action, expect=expect)
except control_files.Conflict as exc:
raise Refused(str(exc), 409)
except OSError as exc:
raise Refused(f"write failed: {exc}", 500)
return p
def save_and_flash(image_type, body, action):
"""save() for the form-post routes: audit and flash the outcome."""
try:
p = save(image_type, body, action)
except Refused as exc:
flash(str(exc), "danger")
return None
if p["changed"]:
audit("UNATTEND_SAVE", f"{image_type} ({body.get('mode')})")
flash(f"Saved {FILE_NAME}. The previous version is next to it "
f"({FILE_NAME}.bak-...-webui) and Undo puts it back.", "success")
else:
flash(f"{FILE_NAME}: nothing changed, nothing written.", "info")
return p
def _unknown_json():
return jsonify({"error": "Unknown image type"}), 404
@bp.route("/<image_type>/unattend/preview", methods=["POST"])
def preview(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown_json()
try:
p, lint = plan(image_type, request.get_json(silent=True))
except Refused as exc:
return jsonify({"error": str(exc)}), exc.status
return jsonify({"files": [control_files.public(p)], "lint": lint, "xml": p["new"],
"changed": p["changed"]})
@bp.route("/<image_type>/unattend/save", methods=["POST"])
def save_route(image_type):
if image_type not in config.IMAGE_TYPES:
return _unknown_json()
body = request.get_json(silent=True)
try:
p = save(image_type, body, action=f"Unattend save ({(body or {}).get('mode')})")
except Refused as exc:
return jsonify({"error": str(exc)}), exc.status
if p["changed"]:
audit("UNATTEND_SAVE", f"{image_type} ({body.get('mode')})")
flash(f"Saved {FILE_NAME}. The previous version is next to it "
f"({FILE_NAME}.bak-...-webui) and Undo puts it back.", "success")
else:
flash(f"{FILE_NAME}: nothing changed, nothing written.", "info")
return jsonify({"ok": True, "changed": p["changed"]})
@bp.route("/<image_type>/unattend/undo", methods=["POST"])
def undo(image_type):
if image_type not in config.IMAGE_TYPES:
flash("Unknown image type.", "danger")
return redirect(url_for("dashboard"))
try:
with _write_lock:
rec = control_files.undo(image_type, UNDO_SCOPE)
audit("UNATTEND_UNDO", f"{image_type} (save of {rec.get('ts')})")
flash(f"Undid the last save: {FILE_NAME} is back to how it was before it. "
f"The undone version is kept as {FILE_NAME}.bak-...-webui-undo.", "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, "unattend"))

113
webapp/views/ipxe.py Normal file
View File

@@ -0,0 +1,113 @@
"""Updates > iPXE: the Secure Boot boot files on the TFTP root.
Shows what is installed (version, signer chain, Microsoft CA warning), how
dnsmasq routes clients to those files, and checks GitHub for a newer
upstream release. Download and verify runs as a background job; install
and rollback copy a few MB and run inline. Logic lives in
services/ipxe_update.py; this module is the route surface.
"""
from flask import Blueprint, flash, jsonify, redirect, render_template, request, url_for
from services import ipxe_update, updates_common
from services.audit import audit
from views import save_speed_cap
bp = Blueprint("ipxe", __name__, url_prefix="/updates/ipxe")
def _back():
return redirect(url_for("ipxe.page"))
@bp.route("/")
def page():
rows = ipxe_update.installed()
by_name = {r["name"]: r["info"] for r in rows}
latest = ipxe_update.last_check()
ipxe_efi = by_name.get("ipxe.efi") or {}
status = ipxe_update.compare(latest["tag"], ipxe_efi.get("version")) if latest else None
return render_template(
"updates_ipxe.html",
rows=rows,
installed_sha={n: (i or {}).get("sha256") for n, i in by_name.items()},
routing=ipxe_update.dnsmasq_routing(),
latest=latest,
status=status,
job=ipxe_update.job_state(),
staged=ipxe_update.staged(),
backups=ipxe_update.backups(),
settings=updates_common.settings(),
default_install=ipxe_update.DEFAULT_INSTALL,
tarball_url=ipxe_update.LATEST_TARBALL_URL,
releases_page=ipxe_update.RELEASES_PAGE,
)
@bp.route("/status.json")
def status():
return jsonify(ipxe_update.job_state())
@bp.route("/check", methods=["POST"])
def check():
try:
info = ipxe_update.check_latest()
except Exception as ex:
flash(f"Could not check GitHub: {ex}", "danger")
return _back()
if not info["sha256"]:
flash(f"Latest release is {info['tag']}, but GitHub lists no sha256 for {ipxe_update.TARBALL}; "
"install it by hand (see below).", "warning")
else:
flash(f"Latest upstream release: {info['tag']}.", "info")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
try:
ipxe_update.start_stage()
except Exception as ex:
flash(f"Download not started: {ex}", "danger")
return _back()
latest = ipxe_update.last_check() or {}
audit("IPXE_STAGE", f"download and verify {latest.get('tag')}")
flash(f"Downloading {latest.get('tag')}; this page updates when it is verified.", "info")
return _back()
@bp.route("/install", methods=["POST"])
def install():
tag = request.form.get("tag", "")
# Only known names reach the audit log (form values could carry newlines).
names = [n for n in ipxe_update.MANAGED if n in request.form.getlist("names")]
try:
backup = ipxe_update.install(tag, names)
except Exception as ex:
flash(f"Install refused: {ex}", "danger")
return _back()
audit("IPXE_INSTALL", f"{tag}: {', '.join(names)} (backup {backup.rsplit('/', 1)[-1]})")
flash(f"Installed {', '.join(names)} from {tag}. "
"The next client to boot gets them; the old files are in the backups list.", "success")
return _back()
@bp.route("/rollback", methods=["POST"])
def rollback():
# rollback() looks the id up by its last path part only; log that.
backup_id = request.form.get("backup", "").replace("\\", "/").rsplit("/", 1)[-1]
try:
files = ipxe_update.rollback(backup_id)
except Exception as ex:
flash(f"Rollback failed: {ex}", "danger")
return _back()
audit("IPXE_ROLLBACK", f"{backup_id}: {', '.join(files)}")
flash(f"Restored {', '.join(files)} from backup {backup_id}.", "success")
return _back()
@bp.route("/settings", methods=["POST"])
def save_settings():
save_speed_cap() # shared with the other Updates pages (views/__init__.py)
return _back()