blancco: live erase progress on the /imaging dashboard
Each Blancco PC now gets a tile on /imaging, keyed by BIOS serial with pctype "blancco", using the /imaging/status endpoint the WinPE clients already post to (stage_total 7): 1 Downloading Blancco, 2 Starting Blancco (blancco-init.sh, wget) 3 Waiting for licence / Licence OK (N left) 4 Erasing: step N - <step> [NN%] 5 Erasure finished / Report saved 6 Report sent to cloud 7 Done (succeeded), or Erasure FAILED (failed, sticks) playbook/blancco-progress.py follows the AlbusSeven journal inside the Blancco rootfs and maps its lines to those stages; patterns taken from a real 7.20 auto-mode run and replayed with --replay/--dry-run, plus a synthetic failed step. Init fetches it from the web root at boot and installs it as blancco-progress.service; the playbook deploys it there. The report copier adds a "Report copied to share" log line. Stage 1 resets a finished tile for the same serial. All posts are best effort.
This commit is contained in:
@@ -132,12 +132,24 @@ ip link set "$IFACE" up || ifconfig "$IFACE" up
|
|||||||
sleep 2
|
sleep 2
|
||||||
|
|
||||||
SERVER=172.16.9.1
|
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
|
ifconfig "$IFACE" 172.16.9.250 netmask 255.255.255.0 up
|
||||||
sleep 1
|
sleep 1
|
||||||
dbg echo " IP: 172.16.9.250 SERVER: $SERVER"
|
dbg echo " IP: 172.16.9.250 SERVER: $SERVER"
|
||||||
dbg ip addr
|
dbg ip addr
|
||||||
|
|
||||||
echo " [3/5] Downloading Blancco (~850 MB)"
|
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
|
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; }
|
[ -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
|
if cp "$f" "/mnt/share/$name.part" && mv -f "/mnt/share/$name.part" "/mnt/share/$name"; then
|
||||||
touch "$DONE/$id"
|
touch "$DONE/$id"
|
||||||
echo "copied $id to $name"
|
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
|
else
|
||||||
rm -f "/mnt/share/$name.part"
|
rm -f "/mnt/share/$name.part"
|
||||||
echo "copy of $id failed, will retry"
|
echo "copy of $id failed, will retry"
|
||||||
@@ -324,6 +340,29 @@ XEOF
|
|||||||
ln -sf /etc/systemd/system/blancco-report-sync.service \
|
ln -sf /etc/systemd/system/blancco-report-sync.service \
|
||||||
/run/newroot/etc/systemd/system/multi-user.target.wants/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
|
# 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.
|
# 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
|
# 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
|
mount --bind /dev/null /run/newroot/sys/power/autosleep 2>/dev/null || true
|
||||||
|
|
||||||
echo " Starting Blancco..."
|
echo " Starting Blancco..."
|
||||||
|
post_status 2 "Starting Blancco"
|
||||||
exec switch_root /run/newroot /sbin/init
|
exec switch_root /run/newroot /sbin/init
|
||||||
|
|||||||
167
playbook/blancco-progress.py
Normal file
167
playbook/blancco-progress.py
Normal file
@@ -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()
|
||||||
@@ -993,6 +993,14 @@
|
|||||||
dest: "{{ web_root }}/blancco/preferences.xml"
|
dest: "{{ web_root }}/blancco/preferences.xml"
|
||||||
mode: '0644'
|
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
|
# Hard gate: blancco-init.sh in initramfs drops to shell if the served
|
||||||
# preferences.xml is malformed or missing the SMB target. Fail the
|
# preferences.xml is malformed or missing the SMB target. Fail the
|
||||||
# playbook here too so the problem surfaces at deploy time, not at the
|
# playbook here too so the problem surfaces at deploy time, not at the
|
||||||
|
|||||||
Reference in New Issue
Block a user