#!/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" CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-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"), ] # Cloud upload of a report: "[comm.manager] Sending report document id: " # then "Report sent to MC" (or an upload failure). SENDING = re.compile(r"Sending report document id: ([0-9a-fA-F-]{36})") SENT = re.compile(r"Report sent to MC") SEND_FAILED = re.compile(r"(report (upload|send|sending)[^\n]*fail|failed to (send|upload) report)", re.I) # 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 cloud(self, document_id, sent, detail): """Tell the webapp whether a report reached the Blancco cloud.""" payload = {"document_id": document_id, "serial": SERIAL, "sent": sent, "detail": detail[:200]} if self.dry_run: print(json.dumps({"cloud": payload})) return req = urllib.request.Request( CLOUD_URL, data=json.dumps(payload).encode(), headers={"Content-Type": "application/json"}) try: urllib.request.urlopen(req, timeout=5).read() except OSError: pass def line(self, text): text = text.strip() if not text: return m = SENDING.search(text) if m: self.sending = m.group(1) elif getattr(self, "sending", None): if SENT.search(text): self.cloud(self.sending, True, text) self.sending = None elif SEND_FAILED.search(text): self.cloud(self.sending, False, text) self.sending = None 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()