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