diff --git a/playbook/blancco-init.sh b/playbook/blancco-init.sh index a0e4c44..cfc36b8 100644 --- a/playbook/blancco-init.sh +++ b/playbook/blancco-init.sh @@ -132,12 +132,24 @@ ip link set "$IFACE" up || ifconfig "$IFACE" up sleep 2 SERVER=172.16.9.1 + +# Coarse progress for the webapp's /imaging dashboard (same endpoint the +# WinPE imaging clients use). Stage 1 also resets a finished tile for this +# serial. Best effort: a webapp outage must never stop a wipe. +post_status() { + serial=$(cat /sys/class/dmi/id/product_serial 2>/dev/null | tr -d '"\\') + [ -n "$serial" ] || return 0 + wget -q -T 5 -O /dev/null --header 'Content-Type: application/json' \ + --post-data "{\"serial\":\"$serial\",\"pctype\":\"blancco\",\"source\":\"blancco\",\"current_stage\":\"$2\",\"stage_index\":$1,\"stage_total\":7,\"status\":\"in_progress\",\"ip\":\"172.16.9.250\"}" \ + "http://$SERVER:9009/imaging/status" 2>/dev/null || true +} ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up sleep 1 dbg echo " IP: 172.16.9.250 SERVER: $SERVER" dbg ip addr echo " [3/5] Downloading Blancco (~850 MB)" +post_status 1 "Downloading Blancco" wget -T 30 -O /tmp/airootfs.sfs http://$SERVER/blancco/arch/x86_64/airootfs.sfs 2>&1 [ -s /tmp/airootfs.sfs ] || { echo "ERROR: download failed"; exec /bin/sh; } @@ -299,6 +311,10 @@ while :; do if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then touch "$DONE/$id" echo "copied $id to $name" + serial=$(tr -d '"\\' < /sys/class/dmi/id/product_serial 2>/dev/null) + [ -n "$serial" ] && curl -s -m 5 -o /dev/null -H 'Content-Type: application/json' \ + -d "{\"serial\":\"$serial\",\"log_lines\":[\"Report copied to share: $name\"]}" \ + http://172.16.9.1:9009/imaging/status || true else rm -f "/mnt/share/$name.part" echo "copy of $id failed, will retry" @@ -324,6 +340,29 @@ XEOF ln -sf /etc/systemd/system/blancco-report-sync.service \ /run/newroot/etc/systemd/system/multi-user.target.wants/blancco-report-sync.service +# Live erase progress for the /imaging dashboard: follows the AlbusSeven +# journal and posts stages and drive percentages. Fetched at boot so the +# server copy (playbook/blancco-progress.py) is the only one. +if wget -q -T 10 -O /run/newroot/usr/local/sbin/blancco-progress "http://$SERVER/blancco/blancco-progress.py"; then + chmod 755 /run/newroot/usr/local/sbin/blancco-progress + cat > /run/newroot/etc/systemd/system/blancco-progress.service << 'XEOF' +[Unit] +Description=Report Blancco erase progress to the PXE webapp + +[Service] +ExecStart=/usr/bin/python3 /usr/local/sbin/blancco-progress +Restart=on-failure +RestartSec=10 + +[Install] +WantedBy=multi-user.target +XEOF + ln -sf /etc/systemd/system/blancco-progress.service \ + /run/newroot/etc/systemd/system/multi-user.target.wants/blancco-progress.service +else + echo " (no blancco-progress.py on server: no live progress on the dashboard)" +fi + # Debug boots only: Blancco's own debug mode (/etc/debug.environment makes # isDebugMode true, so 2_sshd starts sshd) plus an SSH key for root. # Root is locked and sshd is key-only, so without the key there is no way @@ -357,4 +396,5 @@ mount --bind /dev/null /run/newroot/sys/power/mem_sleep 2>/dev/null || true mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true echo " Starting Blancco..." +post_status 2 "Starting Blancco" exec switch_root /run/newroot /sbin/init diff --git a/playbook/blancco-progress.py b/playbook/blancco-progress.py new file mode 100644 index 0000000..9faaf58 --- /dev/null +++ b/playbook/blancco-progress.py @@ -0,0 +1,167 @@ +#!/usr/bin/env python3 +"""Report Blancco erase progress to the PXE webapp's /imaging dashboard. + +Runs inside the Blancco rootfs (installed by blancco-init.sh as a systemd +service). Follows the AlbusSeven journal and maps its log lines onto the +same /imaging/status payload the WinPE imaging clients post, so each +wiping PC gets a tile keyed by BIOS serial with pctype "blancco". + +Stages (stage_total 7): 1 booting and 2 starting are posted by +blancco-init.sh; this script posts 3 waiting for licence, 4 erasing, +5 report saved, 6 report sent / on share, 7 done. A failed erasure step +marks the session failed. + +Usage: blancco-progress.py [--replay FILE] [--dry-run] +""" +import json +import re +import subprocess +import sys +import time +import urllib.request + +STATUS_URL = "http://172.16.9.1:9009/imaging/status" +STAGE_TOTAL = 7 +# Progress lines arrive in bursts; post at most this often unless the +# stage itself changes. +MIN_POST_INTERVAL = 3.0 + + +def read(path): + try: + with open(path) as f: + return f.read().strip() + except OSError: + return "" + + +def pxe_iface(): + """(mac, ip) of the interface on the PXE LAN, or blanks.""" + try: + out = subprocess.run(["ip", "-o", "-4", "addr"], capture_output=True, + text=True, timeout=5).stdout + except (OSError, subprocess.SubprocessError): + return "", "" + for line in out.splitlines(): + m = re.search(r"^\d+:\s+(\S+)\s+inet (172\.16\.9\.\d+)/", line) + if m: + return read(f"/sys/class/net/{m.group(1)}/address"), m.group(2) + return "", "" + + +SERIAL = read("/sys/class/dmi/id/product_serial") or read("/etc/machine-id") +MODEL = " ".join(x for x in (read("/sys/class/dmi/id/sys_vendor"), + read("/sys/class/dmi/id/product_name")) if x) + +# (pattern, stage_index, stage text or callable(match) -> text, status) +RULES = [ + (re.compile(r"Waiting for MC connectivity"), 3, + "Waiting for licence (cloud)", None), + (re.compile(r"ClurichaunLicenseProvider: ([1-9]\d*) licenses available for product id 1\b"), 3, + lambda m: f"Licence OK ({m.group(1)} left)", None), + (re.compile(r"startErasure: success"), 4, "Erasing", None), + (re.compile(r"About to start Step (\d+) \((.*?)\)\.?$"), 4, + lambda m: f"Erasing: step {m.group(1)} - {m.group(2).split(' or ')[0][:60]}", None), + (re.compile(r"Step \d+ \(.*\) failed|Erasure step .* failed\b|erasure failed", re.I), 4, + "Erasure FAILED", "failed"), + (re.compile(r"erasureFinishedForEntity"), 5, "Erasure finished", None), + (re.compile(r"\[report\.manager\].*Report saving success"), 5, "Report saved", None), + (re.compile(r"Report sent to MC"), 6, "Report sent to cloud", None), + (re.compile(r"Auto process reached the final step"), 7, "Done", "succeeded"), +] +# Drive progress, e.g. "FORMAT NVM progress: 75%". Excludes the self-test +# completion and volume lines that also carry a percentage. +PROGRESS = re.compile(r"\bprogress[^%\d]{0,20}(\d{1,3})%", re.I) + + +class Reporter: + def __init__(self, dry_run=False): + self.dry_run = dry_run + self.mac, self.ip = pxe_iface() + self.stage_index = 2 + self.stage = "Blancco started" + self.status = "in_progress" + self.last_post = 0.0 + self.pending = [] + + def post(self, force=False): + now = time.monotonic() + if not force and now - self.last_post < MIN_POST_INTERVAL: + return + payload = { + "serial": SERIAL, + "mac": self.mac, + "ip": self.ip, + "pctype": "blancco", + "hostname_target": MODEL, + "current_stage": self.stage, + "stage_index": self.stage_index, + "stage_total": STAGE_TOTAL, + "status": self.status, + "source": "blancco", + } + if self.pending: + payload["log_lines"] = self.pending[-20:] + if self.dry_run: + print(json.dumps(payload)) + else: + req = urllib.request.Request( + STATUS_URL, data=json.dumps(payload).encode(), + headers={"Content-Type": "application/json"}) + try: + urllib.request.urlopen(req, timeout=5).read() + except OSError: + return # webapp unreachable: keep the lines for the next post + self.pending = [] + self.last_post = now + + def line(self, text): + text = text.strip() + if not text: + return + for pat, idx, stage, status in RULES: + m = pat.search(text) + if not m: + continue + if self.status == "failed" and status != "failed": + return # a failure sticks for the rest of the run + if idx < self.stage_index and status != "failed": + return # late licence chatter must not rewind the tile + new_stage = stage(m) if callable(stage) else stage + changed = (idx, new_stage) != (self.stage_index, self.stage) + self.stage_index = max(self.stage_index, idx) if status != "failed" else idx + self.stage = new_stage + if status: + self.status = status + self.pending.append(text[:200]) + if changed: + self.post(force=True) + return + m = PROGRESS.search(text) + if m and self.stage_index == 4 and "Self-Test" not in text: + base = self.stage.split(" [")[0] + self.stage = f"{base} [{m.group(1)}%]" + self.post() + + +def main(): + dry_run = "--dry-run" in sys.argv + rep = Reporter(dry_run=dry_run) + if "--replay" in sys.argv: + with open(sys.argv[sys.argv.index("--replay") + 1]) as f: + for text in f: + rep.line(text) + rep.post(force=True) + return + rep.post(force=True) + proc = subprocess.Popen( + ["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"], + stdout=subprocess.PIPE, text=True, bufsize=1) + for text in proc.stdout: + rep.line(text) + if rep.status in ("succeeded", "failed") and rep.stage_index == STAGE_TOTAL: + rep.post(force=True) + + +if __name__ == "__main__": + main() diff --git a/playbook/pxe_server_setup.yml b/playbook/pxe_server_setup.yml index f750ad5..bd413b5 100644 --- a/playbook/pxe_server_setup.yml +++ b/playbook/pxe_server_setup.yml @@ -993,6 +993,14 @@ dest: "{{ web_root }}/blancco/preferences.xml" mode: '0644' + # blancco-init.sh fetches this at boot and runs it as a service in the + # Blancco rootfs: erase progress for the webapp's /imaging dashboard. + - name: "Deploy Blancco progress reporter" + copy: + src: "{{ usb_root }}/playbook/blancco-progress.py" + dest: "{{ web_root }}/blancco/blancco-progress.py" + mode: '0644' + # Hard gate: blancco-init.sh in initramfs drops to shell if the served # preferences.xml is malformed or missing the SMB target. Fail the # playbook here too so the problem surfaces at deploy time, not at the