259 lines
8.6 KiB
Python
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()
|