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:
37
webapp/views/__init__.py
Normal file
37
webapp/views/__init__.py
Normal 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
|
||||
214
webapp/views/blancco_update.py
Normal file
214
webapp/views/blancco_update.py
Normal 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
258
webapp/views/bprt_sync.py
Normal 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()
|
||||
179
webapp/views/dell_updates.py
Normal file
179
webapp/views/dell_updates.py
Normal 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
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"))
|
||||
268
webapp/views/images_drivers.py
Normal file
268
webapp/views/images_drivers.py
Normal 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
|
||||
219
webapp/views/images_unattend.py
Normal file
219
webapp/views/images_unattend.py
Normal 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
113
webapp/views/ipxe.py
Normal 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()
|
||||
Reference in New Issue
Block a user