From 12f6f00cdbb3198c67fba4a4a4f8a41d7895fd03 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Mon, 5 Oct 2026 13:59:09 -0400 Subject: [PATCH] blancco-progress: take the final state from the report file First normal (non-debug) run stuck at 6/7 "Report sent to cloud": outside Blancco's debug mode Albus does not log erasureFinishedForEntity, Report saving success or "Auto process reached the final step", which the parser had been taken from a debug-mode journal. A watcher thread now reads each new /reports/*.xml (auto mode always saves there) once it is stable and posts 7: "Wipe complete" (succeeded) or "Erasure FAILED: " (failed). Judged per drive, so a failed Purge with a successful Clear fallback on the same drive is a wipe. --outcome FILE prints the verdict for a report. --- playbook/blancco-progress.py | 87 ++++++++++++++++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/playbook/blancco-progress.py b/playbook/blancco-progress.py index 9d5457f..41c7a3a 100644 --- a/playbook/blancco-progress.py +++ b/playbook/blancco-progress.py @@ -11,14 +11,24 @@ 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. +The final state comes from the report itself, not the journal: outside +Blancco's debug mode Albus does not log "erasureFinishedForEntity", +"Report saving success" or "Auto process reached the final step", so a +watcher on /reports (where auto mode always saves the report) reads each +new report and posts 7 "Wipe complete" or "Erasure FAILED". + Usage: blancco-progress.py [--replay FILE] [--dry-run] """ +import glob import json +import os import re import subprocess import sys +import threading import time import urllib.request +import xml.etree.ElementTree as ET STATUS_URL = "http://172.16.9.1:9009/imaging/status" CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status" @@ -50,6 +60,41 @@ def pxe_iface(): return "", "" +REPORTS_DIR = "/reports" + + +def report_outcome(path): + """(wiped: bool, failed_drives: list[str]) from a Blancco report XML. + + Per drive: wiped if any of its erasures succeeded, so a failed NIST + Purge followed by a successful Clear fallback still counts as wiped. + None if the report has no erasures (hardware-only report). + """ + root = ET.parse(path).getroot() + drives = {} + for i, er in enumerate(root.iter("entries")): + if er.get("name") != "erasure": + continue + state, serial, model = "", "", "" + for e in er: + if e.tag == "entry" and e.get("name") == "state": + state = (e.text or "").strip() + if e.tag == "entries" and e.get("name") == "target": + for t in e: + if t.get("name") == "serial": + serial = (t.text or "").strip() + elif t.get("name") == "model": + model = (t.text or "").strip() + key = serial or f"#{i}" + ok = state.lower() == "successful" + prev = drives.get(key, (False, "")) + drives[key] = (prev[0] or ok, f"{model} {serial}".strip()) + if not drives: + return None + failed = [label for ok, label in drives.values() if not ok] + return (not failed, failed) + + 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) @@ -175,9 +220,50 @@ class Reporter: self.post() + def finish(self, path): + """Final stage from a finished report on disk.""" + try: + outcome = report_outcome(path) + except (OSError, ET.ParseError): + return False + if outcome is None: + return True # nothing erased in this report; keep watching + wiped, failed = outcome + name = os.path.basename(path) + self.stage_index = 7 + if wiped: + self.stage, self.status = "Wipe complete", "succeeded" + else: + self.stage, self.status = "Erasure FAILED: " + ", ".join(failed)[:120], "failed" + self.pending.append(f"Report {name}: {'all drives wiped' if wiped else 'failed: ' + ', '.join(failed)}") + self.post(force=True) + return True + + +def watch_reports(rep, interval=5, stable=5): + """Post the final stage for each new report Albus saves in /reports.""" + seen = set(glob.glob(os.path.join(REPORTS_DIR, "*.xml"))) + while True: + time.sleep(interval) + for path in sorted(glob.glob(os.path.join(REPORTS_DIR, "*.xml"))): + if path in seen: + continue + try: + st = os.stat(path) + except OSError: + continue + if st.st_size == 0 or time.time() - st.st_mtime < stable: + continue # Albus may still be writing it + if rep.finish(path): + seen.add(path) + + def main(): dry_run = "--dry-run" in sys.argv rep = Reporter(dry_run=dry_run) + if "--outcome" in sys.argv: + print(report_outcome(sys.argv[sys.argv.index("--outcome") + 1])) + return if "--replay" in sys.argv: with open(sys.argv[sys.argv.index("--replay") + 1]) as f: for text in f: @@ -185,6 +271,7 @@ def main(): rep.post(force=True) return rep.post(force=True) + threading.Thread(target=watch_reports, args=(rep,), daemon=True).start() proc = subprocess.Popen( ["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"], stdout=subprocess.PIPE, text=True, bufsize=1)