Files
pxe-server/webapp/views/images_unattend.py
cproudlock 397063f987 Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
2026-10-13 16:58:55 -04:00

220 lines
9.2 KiB
Python

"""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"))