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/.
271 lines
9.2 KiB
Python
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()
|