Files
pxe-server/webapp/views/bprt_sync.py
cproudlock 04db6d6605 Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo
(pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers,
CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the
WJ preinstall list, and the WJ shopfloor PC types (Collections,
NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace,
Display, PartMarker) with GE-Enforce, machine numbers and the desktop
layout. The part-marker bench tools moved to their own repo. Dead
scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models,
mirror-from-gold, pull-bios, startnet-template.cmd.

Shopfloor is one generic type, gea-shopfloor-standard, with today's
timing:
- PPKG at first logon (unattend Order 10, unchanged).
- Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup.
- A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once
  after the post-PPKG reboot. It also clears AutoLogonCount after the
  ShopFloor handover (the Users-level monitor can be denied that HKLM
  write) and removes the Intune Sync task when done.
- Both lists ship empty. A site adds entries and installers.
- WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal
  carry only the generic payload. The webapp's shopfloor sub-menu editor
  is gone, and the imaging tiles name the new stages.
- The playbook deploys the empty lists only if missing. It now deploys
  the PowerShell 7 MSI the shopfloor unattend installs, which it never
  did before.

Blancco site settings move into the webapp (Updates > Blancco releases):
business location, licensing Wi-Fi SSID and password. They are rendered
into the served preferences.xml and applied to every staged release, and
are seeded from site-vars (site_name, blancco_wifi_ssid,
blancco_wifi_password) only while none exist. The repo XML carries no
site values; the plaintext Wi-Fi password is gone from it.

New site_name and site_timezone settings: report and evidence-pack site
name, server clock and webapp time zone. No more West Jefferson defaults
in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the
server address filled in at download instead of WJ's GE address.

USB on Windows:
- scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It
  writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus
  payload) with diskpart and reads every file back by SHA-256.
- Tested with Windows PowerShell 5.1 in the win11 VM, and the layout
  installs and first-boots in the pxetest VM.
- scripts/stage-usb-share.sh stages the build folder on a share.
- site-content/ is optional for both builders.

KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment,
Windows only, with a site-vars.yml guide.

Checks: webapp tests pass (5 suites); the unattend lint and playbook
syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names
or PC types in playbook/, webapp/, scripts/ or autoinstall/.
2026-10-16 20:37:22 -04:00

271 lines
9.2 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.
"""
import re
from flask import (Blueprint, abort, flash, jsonify, redirect, render_template,
Response, 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)
# The server address as the work PC reached it (its GE address, port
# 9009) goes in as the default, so the script needs no edits.
host = request.host.split(":")[0]
if not re.fullmatch(r"[A-Za-z0-9.-]{1,253}", host):
abort(400)
with open(path, encoding="utf-8") as f:
text = f.read()
text = text.replace("[string]$WebUrl = '',", f"[string]$WebUrl = 'http://{host}:9009',", 1)
text = text.replace("[string]$SshHost = '',", f"[string]$SshHost = '{host}',", 1)
return Response(text, mimetype="text/plain",
headers={"Content-Disposition": "attachment; filename=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()