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: <model serial, ...>" (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.
This commit is contained in:
cproudlock
2026-10-05 13:59:09 -04:00
parent 020f043d8b
commit 12f6f00cdb

View File

@@ -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 5 report saved, 6 report sent / on share, 7 done. A failed erasure step
marks the session failed. 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] Usage: blancco-progress.py [--replay FILE] [--dry-run]
""" """
import glob
import json import json
import os
import re import re
import subprocess import subprocess
import sys import sys
import threading
import time import time
import urllib.request import urllib.request
import xml.etree.ElementTree as ET
STATUS_URL = "http://172.16.9.1:9009/imaging/status" STATUS_URL = "http://172.16.9.1:9009/imaging/status"
CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status" CLOUD_URL = "http://172.16.9.1:9009/reports/cloud-status"
@@ -50,6 +60,41 @@ def pxe_iface():
return "", "" 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") 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"), MODEL = " ".join(x for x in (read("/sys/class/dmi/id/sys_vendor"),
read("/sys/class/dmi/id/product_name")) if x) read("/sys/class/dmi/id/product_name")) if x)
@@ -175,9 +220,50 @@ class Reporter:
self.post() 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(): def main():
dry_run = "--dry-run" in sys.argv dry_run = "--dry-run" in sys.argv
rep = Reporter(dry_run=dry_run) 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: if "--replay" in sys.argv:
with open(sys.argv[sys.argv.index("--replay") + 1]) as f: with open(sys.argv[sys.argv.index("--replay") + 1]) as f:
for text in f: for text in f:
@@ -185,6 +271,7 @@ def main():
rep.post(force=True) rep.post(force=True)
return return
rep.post(force=True) rep.post(force=True)
threading.Thread(target=watch_reports, args=(rep,), daemon=True).start()
proc = subprocess.Popen( proc = subprocess.Popen(
["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"], ["journalctl", "-f", "-b", "-o", "cat", "-t", "AlbusSeven", "-n", "all"],
stdout=subprocess.PIPE, text=True, bufsize=1) stdout=subprocess.PIPE, text=True, bufsize=1)