Files
pxe-server/webapp/views/blancco_update.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

238 lines
8.4 KiB
Python

"""Updates > Blancco: install a new Blancco Drive Eraser release, test first.
Upload (or pick) the vendor ISO / USB image, stage it as a background job
into WEB_ROOT/blancco/staged/<version>/, boot it from a test entry in the
iPXE menu, then activate it (backup + atomic swap) or roll back. Staging is
a background job (systemd-run) whose progress JSON the page polls; the rest
are renames and small file writes and run inline. Logic lives in
services/blancco_update.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 blancco_prefs
from services import blancco_update as bu
from services.audit import audit
bp = Blueprint("blancco_update", __name__, url_prefix="/updates/blancco")
def _back():
return redirect(url_for("blancco_update.page"))
@bp.route("/")
def page():
job = bu.job_state()
return render_template(
"updates_blancco.html",
current=bu.current(),
staged=bu.staged(),
backups=bu.backups(),
sources=bu.sources(),
job=job,
job_running=job.get("state") == "running",
test=bu.test_entry(),
initrd=bu.initrd_supports_base(),
upload_dir=bu.upload_dir(),
source_dirs=bu.source_dirs(),
server=bu.SERVER,
site=blancco_prefs.public(),
)
@bp.route("/site-settings", methods=["POST"])
def site_settings():
"""Business location and licensing Wi-Fi: saved, then the served
preferences.xml is rendered again. The password is never shown back."""
try:
with bu._lock:
blancco_prefs.save(request.form.get("business_location", ""),
request.form.get("wifi_ssid", ""),
request.form.get("wifi_password", ""))
changed = blancco_prefs.render()
except (OSError, ValueError) as e:
flash(f"Blancco site settings not saved: {e}", "danger")
return _back()
s = blancco_prefs.public()
audit("BLANCCO_SITE_SETTINGS", f"location={s['business_location']!r} ssid={s['wifi_ssid']!r} "
f"password={'set' if s['wifi_password_set'] else 'empty'}")
flash("Blancco site settings saved" + (" and preferences.xml updated." if changed else " (no change)."),
"success")
return _back()
@bp.route("/status.json")
def status():
job = bu.job_state()
return jsonify({k: job.get(k) for k in ("state", "step", "percent", "message", "log",
"version", "source", "started", "finished")})
@bp.route("/upload", methods=["POST"])
def upload():
"""Raw body (the page's fetch upload, name in ?name=) streams straight to
disk; a plain multipart form works too."""
f = request.files.get("release_file")
try:
if f is not None:
name, size = bu.save_upload(f.stream, f.filename)
else:
name, size = bu.save_upload(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("BLANCCO_UPLOAD", f"{name} ({size} bytes)")
if f is None:
return jsonify({"ok": True, "name": name, "size": size})
flash(f"Uploaded {name}.", "success")
return _back()
@bp.route("/stage", methods=["POST"])
def stage():
key, _, name = request.form.get("source", "").partition(":")
try:
path = bu.resolve_source(key, name)
bu.start_stage(path)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Staging not started: {ex}", "danger")
return _back()
audit("BLANCCO_STAGE", path)
flash(f"Staging {name}. This takes a minute or two; the page follows the job.", "info")
return _back()
@bp.route("/cancel", methods=["POST"])
def cancel():
bu.cancel_stage()
audit("BLANCCO_STAGE_CANCEL", "")
flash("Staging cancelled; partial files removed.", "warning")
return _back()
@bp.route("/prefs/<version>", methods=["POST"])
def merged_prefs(version):
f = request.files.get("prefs_file")
if not f or not f.filename:
flash("Choose the merged preferences.xml first.", "danger")
return _back()
try:
meta = bu.save_merged_prefs(version, f.read(4 << 20))
except bu.StageBlocked as ex:
flash(str(ex), "danger")
return _back()
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Preferences not accepted: {ex}", "danger")
return _back()
audit("BLANCCO_PREFS_MERGE", f"{version} preferences {meta.get('prefs_version')}")
flash(f"Merged preferences accepted; Blancco {version} is staged and ready to test. "
"Commit the same file to playbook/blancco-preferences.xml so a playbook run "
"does not put the old one back.", "success")
return _back()
@bp.route("/vendor-prefs/<version>.xml")
def vendor_prefs(version):
try:
path = bu.vendor_prefs_file(version)
except ValueError:
abort(404)
if not path:
abort(404)
return send_file(path, mimetype="application/xml", as_attachment=True,
download_name=f"vendor-preferences-{version}.xml")
@bp.route("/test/<version>", methods=["POST"])
def test_add(version):
try:
backup = bu.add_test_entry(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Test entry not added: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_ADD", f"{version} (menu backup {backup})")
flash(f"Boot menu now has \"Blancco Drive Eraser {version} (STAGED - TEST)\". "
"PXE boot a test machine and pick it.", "success")
return _back()
@bp.route("/test/remove", methods=["POST"])
def test_remove():
try:
backup = bu.remove_test_entry()
except (RuntimeError, OSError) as ex:
flash(f"Test entry not removed: {ex}", "danger")
return _back()
audit("BLANCCO_TEST_ENTRY_REMOVE", f"menu backup {backup}" if backup else "none present")
flash("Test entry removed from the boot menu." if backup else "No test entry in the menu.", "success")
return _back()
@bp.route("/activate/<version>", methods=["POST"])
def activate(version):
try:
backup, files, removed = bu.activate(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not activated: {ex}", "danger")
return _back()
audit("BLANCCO_ACTIVATE", f"{version}; previous release saved in {backup}")
flash(f"Blancco {version} is live ({len(files)} files swapped). The previous release is in "
f"{backup}." + (" The test menu entry was removed." if removed is True else ""), "success")
if isinstance(removed, str):
flash(f"Blancco {version} is live, but {removed}.", "warning")
return _back()
@bp.route("/rollback/<name>", methods=["POST"])
def rollback(name):
try:
backup, files = bu.rollback(name)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not rolled back: {ex}", "danger")
return _back()
audit("BLANCCO_ROLLBACK", f"restored {name}; the replaced release saved in {backup}")
flash(f"Restored {name} ({len(files)} files); its folder is released now that the files are "
f"live. What was live before is saved in {backup}.", "success")
return _back()
@bp.route("/staged/<version>/delete", methods=["POST"])
def delete_staged(version):
try:
bu.delete_staged(version)
except (ValueError, RuntimeError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_STAGED", version)
flash(f"Deleted staged {version}.", "success")
return _back()
@bp.route("/backup/<name>/delete", methods=["POST"])
def delete_backup(name):
try:
bu.delete_backup(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_BACKUP", name)
flash(f"Deleted {name}.", "success")
return _back()
@bp.route("/uploads/<name>/delete", methods=["POST"])
def delete_upload(name):
try:
bu.delete_upload(name)
except (ValueError, OSError) as ex:
flash(f"Not deleted: {ex}", "danger")
return _back()
audit("BLANCCO_DELETE_UPLOAD", name)
flash(f"Deleted {name}.", "success")
return _back()