Files
pxe-server/webapp/views/bprt_sync.py
cproudlock 6187219378 BPRT sync: point the page and docstrings at static/Sync-BPRT.ps1
Follow-up to 92f1e62, which moved the file but missed these references.
2026-10-13 17:12:51 -04:00

259 lines
8.6 KiB
Python

"""Updates > BPRT: bring the vendor bulk-enroll folder over from the work PC.
The work PC runs Sync-BPRT.ps1 (webapp/static, downloadable here), 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()