Files
pxe-server/webapp/services/bprt_sync.py
cproudlock 397063f987 Webapp: Updates pages, image page tabs with review-before-save, BPRT sync
Updates group:
- Dell drivers and BIOS: check Dell's catalogs against all three driver maps
  and models.txt, download with a shared speed cap, repack, preview, apply
  with backups and rollback.
- iPXE boot files: check GitHub for the signed Secure Boot set, verify the
  sha256 and each file's signer, install atomically, roll back. Includes the
  by-hand procedure.
- Blancco: upload a release, stage it, test-boot it from the menu, activate,
  roll back.
- BPRT sync: validate a batch uploaded from the MediaCreator Lite cache
  (scripts/windows/Sync-BPRT.ps1 or a zip), show the diff, swap with the site
  overlay files kept.

Images: one sidebar entry per image with Overview, Drivers, Unattend and OS and
Packages tabs. Every save shows a diff first, keeps the file's format, makes a
backup and can be undone. Driver lint runs inline.

Enrollment: upload standard/engineer MCL packages, one per tenant/purpose,
with archive and restore. Hardware coverage counts engineer as Win11.
2026-10-13 16:58:55 -04:00

1468 lines
58 KiB
Python

"""Bring the vendor BPRT folder from the work PC into _shared/Applications/BPRT.
BPRT (every image's Deploy/Applications/BPRT links to it; PESetup copies it to
every PC while it images) is a vendor folder of about 7.7 GB: the six MCL
packages, the Office zips and the bulk-enroll orchestrator scripts. The vendor
keeps it current on the user's GE work PC with MediaCreator Lite; this server
is only reachable from there over the GE network (this webapp and SSH, no
SMB), and never reaches out itself. So the user runs
scripts/windows/Sync-BPRT.ps1 there, which:
1. GETs manifest.json (path, size, sha256 of every vendor file here);
2. hashes its own copy and uploads only new or changed files, plus a
sync.json listing the complete intended vendor folder, with one sftp
batch into BPRT-incoming/<batch>/.
This module then, each step a background job (python3 -m services.bprt_sync):
* manifest hashes the live folder (cached by path, size and mtime in
UPDATES_DIR/bprt/hashcache.json; only the first run reads all
7.7 GB);
* validate checks an incoming batch: every listed file uploaded with the
right hash or unchanged here, the vendor's one-package-per-prefix
rule (services/mcl_ppkg.py), the scripts the orchestrator calls;
and works out the diff;
* activate builds BPRT.new from hard links (live files that did not change,
plus the incoming ones), applies the site overlay, and swaps it
in with two renames: BPRT to BPRT-archive/full-<ts>, BPRT.new to
BPRT. Rollback swaps an archive back the same way.
* unzip the fallback when SSH is not available: a zip uploaded on the
page is unpacked into a new incoming batch, then validated.
Hard links: an archive shares every unchanged file with the live folder, so
it costs only the files that changed. Nothing here edits a file in place;
replace files, never rewrite them, or the archives change with them.
"""
from __future__ import annotations
import errno
import fcntl
import hashlib
import json
import os
import re
import shutil
import signal
import stat
import sys
import time
import zipfile
from collections import Counter
from datetime import datetime, timezone
from services import mcl_ppkg
from services import updates_common as uc
UNIT = "pxe-bprt-sync"
# The site overlay: files in the live BPRT that are ours, not the vendor's.
# manifest.json leaves them out (the work PC never sees or replaces them) and
# activation carries them into every new BPRT from the live one.
# Select-PCConfig.ps1 our shim, repo playbook/shopfloor-setup/BPRT/
# Select-PCConfig.ps1: returns the tenant and
# purpose startnet.cmd pre-seeded, else runs the
# vendor dialog, which is kept beside it as
# Select-PCConfig-vendor.ps1 (VENDOR_RENAMED);
# Register-BulkEnrollTask-Standard.ps1 registers the orchestrator; specialize Order 39
# of playbook/FlatUnattendW10.xml and
# FlatUnattendW10-engineer.xml runs it. It exists
# only on the share (no repo copy);
# Register-BulkEnrollTask.ps1 the shopfloor -ManualFallback variant. Nothing
# calls it since the shopfloor unattend dropped
# its Order 16 (2026-08-06); kept as it was.
OVERLAY = ("Select-PCConfig.ps1", "Register-BulkEnrollTask-Standard.ps1",
"Register-BulkEnrollTask.ps1")
# Vendor files the overlay displaces, and the name the vendor's copy keeps.
VENDOR_RENAMED = {"Select-PCConfig.ps1": "Select-PCConfig-vendor.ps1"}
# Our shim calls the vendor copy by this name; the vendor dialog never does.
SHIM_MARKER = b"Select-PCConfig-vendor.ps1"
# Overlay files enrollment cannot work without (the unattends call it).
OVERLAY_REQUIRED = ("Register-BulkEnrollTask-Standard.ps1",)
# Vendor files the bulk-enroll flow calls by path (C:\Deploy\Applications\BPRT\...):
# a drop without one of them breaks enrollment on every PC. Read from
# Start-BulkEnrollOrchestrator.ps1 and the scripts it starts, plus the two the
# unattends reach. Translations\*.psd1 are optional (WCD-Helpers.ps1 falls
# back to English).
REQUIRED = (
"Start-BulkEnrollOrchestrator.cmd", # what Register-BulkEnrollTask-Standard.ps1 registers
"Start-BulkEnrollOrchestrator.ps1",
"WCD-Helpers.ps1", # dot-sourced by the orchestrator and every dialog
"Show-ImageUntilNetworkConnected.ps1",
"Waiting For Internet Connection.png",
"Select-PCConfig.ps1", # the vendor dialog, kept as Select-PCConfig-vendor.ps1
"GE Aerospace Transparent Logo.png",
"Show-PleaseWait.ps1",
"Translations.json",
"ForegroundWindowWithProgress.exe",
"Insert-BPRTToPPKG.ps1",
"Send-OEMSkipPA.ps1",
"Wait-IntuneEnrollmentAndReboot.ps1",
"Show-VanillaConfirmation.ps1", # the unattends' 'vanilla' Shift+F10 alias
)
# Windows PowerShell 5.1 reads a script without a BOM as ANSI, which garbles
# any non-ASCII text in it; the vendor ships five such scripts. Activation
# gives those a UTF-8 BOM (what was done by hand for the v2.19 drop), and the
# manifest also reports their hash without it so the work PC does not see
# them as changed.
BOM = b"\xef\xbb\xbf"
SCRIPT_EXTS = (".ps1", ".psm1", ".psd1")
_BATCH = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]{0,99}$")
_ARCHIVE = re.compile(r"^full-\d{8}-\d{6}(-\d+)?$")
_WIN_BAD = re.compile(r'[<>:"|?*\\\x00-\x1f]')
_DEFAULT_FILE_MODE = 0o744 # what the share's BPRT files carry (see mcl_ppkg)
# --- paths -------------------------------------------------------------------
def bprt_dir():
return mcl_ppkg.bprt_dir()
def new_dir():
return bprt_dir() + ".new"
def archive_root():
return mcl_ppkg.archive_dir()
def incoming_root():
return os.path.join(os.path.dirname(bprt_dir()), "BPRT-incoming")
def uploads_dir():
return os.path.join(incoming_root(), ".uploads")
def _state(*parts):
return uc.state_dir("bprt", *parts)
def _path(name):
return os.path.join(_state(), name)
def _stamp():
return datetime.now().strftime("%Y%m%d-%H%M%S")
def check_batch(name):
name = str(name or "")
if not _BATCH.match(name) or name.endswith(".part"):
raise ValueError(f"{name or '(blank)'} is not an incoming batch name")
return name
def batch_dir(name):
return os.path.join(incoming_root(), check_batch(name))
def _owner():
"""(uid, gid, mode) of the live BPRT folder, else of its parent."""
for p in (bprt_dir(), os.path.dirname(bprt_dir())):
try:
st = os.stat(p)
return st.st_uid, st.st_gid, stat.S_IMODE(st.st_mode)
except OSError:
continue
return None, None, 0o755
def _match_owner(path, mode):
uid, gid, _ = _owner()
try:
if uid is not None:
os.chown(path, uid, gid)
os.chmod(path, mode)
except OSError:
pass # not root (local tests): the files are ours already
def ensure_incoming():
"""BPRT-incoming, writable by the share owner (the SSH user uploads as pxe)."""
path = incoming_root()
if not os.path.isdir(path):
os.makedirs(path, exist_ok=True)
_match_owner(path, _owner()[2] or 0o755)
return path
# --- walking and hashing -----------------------------------------------------
def walk(root):
"""({relpath: stat} of the regular files under root, [skipped relpaths]).
Relpaths use forward slashes; symlinks and special files are skipped."""
files, skipped = {}, []
if not os.path.isdir(root):
return files, skipped
for here, dirs, names in os.walk(root):
dirs.sort()
rel_here = os.path.relpath(here, root)
for d in list(dirs):
if os.path.islink(os.path.join(here, d)):
dirs.remove(d)
skipped.append(_rel(rel_here, d) + "/")
for n in sorted(names):
rel = _rel(rel_here, n)
st = os.lstat(os.path.join(here, n))
if stat.S_ISREG(st.st_mode):
files[rel] = st
else:
skipped.append(rel)
return files, skipped
def _rel(rel_here, name):
return name if rel_here == "." else f"{rel_here.replace(os.sep, '/')}/{name}"
def _is_script(rel):
return rel.lower().endswith(SCRIPT_EXTS)
def hash_file(path, rel=None, progress=None):
"""(sha256, sha256 of the content after a UTF-8 BOM or None), uppercase,
in one read. The second is only computed for scripts that start with a
BOM."""
h, h2 = hashlib.sha256(), None
script = _is_script(rel or path)
with open(path, "rb") as f:
first = True
for block in iter(lambda: f.read(1 << 22), b""):
h.update(block)
if first:
first = False
if script and block.startswith(BOM):
h2 = hashlib.sha256(block[len(BOM):])
elif h2 is not None:
h2.update(block)
if progress:
progress(len(block))
return h.hexdigest().upper(), (h2.hexdigest().upper() if h2 else None)
def _key(rel, st):
return f"{rel}|{st.st_size}|{st.st_mtime_ns}"
class HashCache:
"""sha256 per (relpath, size, mtime), shared by the live folder, the
archives and BPRT.new: a hard link keeps size and mtime, so a file that
only moved between them is never read again."""
def __init__(self):
data = uc.read_json(_path("hashcache.json"), {}) or {}
self.entries = data.get("entries") if isinstance(data.get("entries"), dict) else {}
self.dirty = False
def get(self, rel, st):
e = self.entries.get(_key(rel, st))
if e and time.time() - e.get("seen", 0) > 86400:
e["seen"] = int(time.time()) # still in use: keep it past the pruning age
self.dirty = True
return e
def put(self, rel, st, sha, nobom):
self.entries[_key(rel, st)] = {"sha256": sha, "nobom": nobom, "seen": int(time.time())}
self.dirty = True
def hash(self, root, rel, st, progress=None):
"""Cached entry for root/rel, hashing it on a miss. A file that
changed while it was read is returned but not cached."""
e = self.get(rel, st)
if e:
return e
path = os.path.join(root, *rel.split("/"))
sha, nobom = hash_file(path, rel, progress)
after = os.lstat(path)
e = {"sha256": sha, "nobom": nobom}
if (after.st_size, after.st_mtime_ns) == (st.st_size, st.st_mtime_ns):
self.put(rel, st, sha, nobom)
return e
def save(self, max_age_days=180):
if not self.dirty:
return
cutoff = time.time() - max_age_days * 86400
self.entries = {k: v for k, v in self.entries.items() if v.get("seen", 0) >= cutoff}
uc.write_json(_path("hashcache.json"), {"entries": self.entries})
self.dirty = False
def _same(entry, sha):
"""True when a file with this cache entry is the vendor file with this
sha256 (with or without the BOM we add)."""
sha = str(sha or "").upper()
return bool(sha) and sha in (entry.get("sha256"), entry.get("nobom"))
# --- the live folder ---------------------------------------------------------
def _lc(names):
return {n.lower() for n in names}
def is_shim(path):
try:
with open(path, "rb") as f:
return SHIM_MARKER.lower() in f.read(1 << 20).lower()
except OSError:
return False
def vendor_view(root, files):
"""{vendor path: relpath in root} for a BPRT folder: the overlay left
out, Select-PCConfig-vendor.ps1 under the vendor's own name. A folder the
overlay was never applied to still has the vendor's Select-PCConfig.ps1."""
overlay = _lc(OVERLAY)
back = {v.lower(): k for k, v in VENDOR_RENAMED.items()}
out = {}
for rel in files:
low = rel.lower()
if low in back:
out[back[low]] = rel
elif low not in overlay:
out[rel] = rel
for vendor, kept in VENDOR_RENAMED.items():
if vendor not in out and vendor in files and not is_shim(os.path.join(root, vendor)):
out[vendor] = vendor
return out
def overlay_sources(root, files):
"""{overlay name: absolute source path} found in a BPRT folder; our shim
falls back to the repo copy when the webapp runs from a checkout."""
out = {}
for name in OVERLAY:
path = os.path.join(root, name)
if name in files and (name not in VENDOR_RENAMED or is_shim(path)):
out[name] = path
if "Select-PCConfig.ps1" not in out:
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
repo = os.path.join(here, "..", "playbook", "shopfloor-setup", "BPRT", "Select-PCConfig.ps1")
if is_shim(repo):
out["Select-PCConfig.ps1"] = os.path.abspath(repo)
return out
def manifest(build=False, progress=None):
"""What manifest.json serves: every vendor file of the live BPRT with size
and sha256 (and sha256_nobom where we added a BOM). Returns None when a
file is not hashed yet and build is False (the caller starts the job)."""
root = bprt_dir()
if os.path.islink(root) or not os.path.isdir(root):
raise RuntimeError(f"{root} is missing or not a folder")
files, _ = walk(root)
view = vendor_view(root, files)
cache = HashCache()
missing = [rel for rel in view.values() if not cache.get(rel, files[rel])]
if missing and not build:
return None
if progress:
progress.start(sum(files[r].st_size for r in missing), len(missing))
out = []
for vpath in sorted(view, key=str.lower):
rel = view[vpath]
st = files[rel]
if progress:
progress.current(vpath)
e = cache.hash(root, rel, st, progress.add if progress else None)
row = {"path": vpath, "size": st.st_size, "sha256": e["sha256"]}
if e.get("nobom"):
row["sha256_nobom"] = e["nobom"]
if rel != vpath:
row["live_name"] = rel
out.append(row)
cache.save()
return {
"format": 1,
"generated": uc.now(),
"bprt": root,
"incoming": incoming_root(),
# Never uploaded: the site files the vendor does not also ship.
"overlay": [n for n in OVERLAY if n not in VENDOR_RENAMED],
"renamed": dict(VENDOR_RENAMED),
"count": len(out),
"bytes": sum(r["size"] for r in out),
"files": out,
}
def _bom_less_non_ascii(path):
"""True for a script PowerShell 5.1 would misread: valid UTF-8 with
non-ASCII text and no BOM."""
try:
with open(path, "rb") as f:
data = f.read()
except OSError:
return False
if data.startswith(BOM) or data.isascii():
return False
try:
data.decode("utf-8")
except UnicodeDecodeError:
return False # ANSI already: PowerShell reads it as written
return True
def live_summary():
"""Facts for the page about the live BPRT (no hashing)."""
root = bprt_dir()
out = {"path": root, "exists": os.path.isdir(root), "is_link": os.path.islink(root),
"count": 0, "bytes": 0, "newest": None, "overlay": [], "missing_required": [],
"bom_needed": [], "skipped": []}
if not out["exists"]:
return out
files, out["skipped"] = walk(root)
view = vendor_view(root, files)
out["count"] = len(files)
out["bytes"] = sum(st.st_size for st in files.values())
out["newest"] = max((st.st_mtime for st in files.values()), default=None)
srcs = overlay_sources(root, files)
for name in OVERLAY:
row = {"name": name, "present": name in files, "ok": name in srcs and srcs[name].startswith(root)}
if name in VENDOR_RENAMED:
row["vendor_copy"] = VENDOR_RENAMED[name] in files
row["is_shim"] = row["ok"]
out["overlay"].append(row)
out["missing_required"] = [r for r in REQUIRED if r not in view]
out["bom_needed"] = [rel for rel in files if _is_script(rel)
and _bom_less_non_ascii(os.path.join(root, *rel.split("/")))]
return out
# --- job state ---------------------------------------------------------------
def job_state():
state = uc.read_json(_path("job.json"), {}) or {}
running = bool(state.get("state") == "running" and uc.job_running(UNIT))
if state.get("state") == "running" and not running:
state["state"] = "interrupted"
state["running"] = running
total = state.get("total_bytes") or 0
done = state.get("done_bytes") or 0
state["percent"] = min(100.0, round(100.0 * done / total, 1)) if total else None
return state
def _job_update(**kw):
state = uc.read_json(_path("job.json"), {}) or {}
state.update(kw)
uc.write_json(_path("job.json"), state)
class Progress:
"""Byte progress of a job, written to job.json at most once a second."""
def __init__(self, step=""):
self.done = 0
self.last = 0.0
if step:
_job_update(step=step)
def start(self, total, count=0):
self.done = 0
_job_update(total_bytes=total, done_bytes=0, files=count)
def current(self, name):
self.name = name
def add(self, n):
self.done += n
if time.time() - self.last >= 1:
self.last = time.time()
_job_update(done_bytes=self.done, current=getattr(self, "name", ""))
def step(self, text):
_job_update(step=text, current="")
def busy():
"""Kind of the running job, or ''."""
st = job_state()
return st.get("kind", "") if st.get("running") else ""
def start(kind, arg=""):
"""Start a background job; one at a time (they all read or swap BPRT)."""
if kind not in ("manifest", "validate", "activate", "unzip"):
raise ValueError(f"unknown job {kind}")
if busy() or uc.job_running(UNIT):
raise RuntimeError(f"a BPRT job ({busy() or 'unknown'}) is still running")
if kind in ("validate", "activate"):
check_batch(arg)
if kind == "unzip":
clean_zip_name(arg) # also keeps a name like --help out of the job's argv
if kind == "activate":
res = validation(arg)
if not res or res.get("state") != "valid":
raise RuntimeError("validate this batch first; only a valid batch can be activated")
uc.write_json(_path("job.json"), {"kind": kind, "arg": arg, "state": "running",
"started": uc.now(), "step": "Starting"})
try:
uc.start_job(UNIT, ["services.bprt_sync", kind] + ([arg] if arg else []))
except Exception as ex:
if not uc.job_running(UNIT): # lost a race to another start: leave its state alone
_job_update(state="error", finished=uc.now(), error=str(ex)[:400])
raise
def run_job(kind, arg=""):
"""Body of a job (also called directly by tests): runs it and records the
outcome in job.json."""
_job_update(kind=kind, arg=arg, state="running", error="", result="",
total_bytes=0, done_bytes=0, current="")
try:
if kind == "manifest":
m = manifest(build=True, progress=Progress("Hashing the live BPRT"))
result = f"{m['count']} files hashed"
elif kind == "validate":
res = validate(arg, Progress("Validating"))
result = res["state"]
elif kind == "activate":
res = activate(arg, Progress("Building BPRT.new"))
result = f"activated, previous BPRT in {res['archive']}"
elif kind == "unzip":
batch = unzip(arg, Progress("Unpacking"))
res = validate(batch, Progress("Validating"))
result = f"unpacked into {batch}: {res['state']}"
else:
raise ValueError(f"unknown job {kind}")
except Exception as ex:
_job_update(state="error", finished=uc.now(), error=str(ex)[:600], step="")
raise
st = uc.read_json(_path("job.json"), {}) or {}
_job_update(state="done", finished=uc.now(), result=result, step="", current="",
done_bytes=st.get("total_bytes") or 0)
return result
class _Busy(RuntimeError):
pass
class _Lock:
"""Exclusive lock around anything that builds or swaps BPRT, shared by the
jobs, the inline rollback and recover()."""
def __enter__(self):
self.fd = os.open(_path(".lock"), os.O_CREAT | os.O_RDWR, 0o644)
try:
fcntl.flock(self.fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
os.close(self.fd)
raise _Busy("another BPRT activation or rollback is running") from None
return self
def __exit__(self, *exc):
fcntl.flock(self.fd, fcntl.LOCK_UN)
os.close(self.fd)
return False
# --- incoming batches --------------------------------------------------------
def _read_sync(folder):
path = os.path.join(folder, "sync.json")
try:
with open(path, encoding="utf-8-sig") as f:
data = json.load(f)
except OSError:
return None
except ValueError as ex:
raise ValueError(f"sync.json is not valid JSON: {ex}") from None
if not isinstance(data, dict):
raise ValueError("sync.json is not a JSON object")
return data
def _list(value):
"""A JSON list from sync.json. Windows PowerShell 5.1's ConvertTo-Json
writes an array that carries PowerShell's extra type data as
{"value": [...], "Count": n}; take the list out of that too."""
if isinstance(value, dict) and isinstance(value.get("value"), list):
value = value["value"]
return value if isinstance(value, list) else []
def validation(name):
return uc.read_json(os.path.join(_state("batches"), f"{check_batch(name)}.json"), None)
def batches():
"""Incoming batches, newest first: name, files, bytes, sync.json facts and
the last validation. A '<name>.part' folder is an sftp upload still
running (Sync-BPRT.ps1 renames it when done)."""
root = incoming_root()
out = []
if not os.path.isdir(root):
return out
live_files = None
for name in os.listdir(root):
folder = os.path.join(root, name)
if name.startswith(".") or not os.path.isdir(folder) or os.path.islink(folder):
continue
uploading = name.endswith(".part")
base = name[:-5] if uploading else name
if not _BATCH.match(base):
continue
files, _ = walk(folder)
row = {"name": name, "uploading": uploading,
"files": len([f for f in files if f != "sync.json"]),
"bytes": sum(st.st_size for f, st in files.items() if f != "sync.json"),
"modified": max([st.st_mtime for st in files.values()] or [os.stat(folder).st_mtime]),
"sync": None, "sync_error": "", "validation": None}
if not uploading:
try:
sync = _read_sync(folder)
if sync:
row["sync"] = {k: sync.get(k) for k in ("computer", "user", "created", "source")}
row["sync"]["listed"] = len(_list(sync.get("files")))
except ValueError as ex:
row["sync_error"] = str(ex)
res = validation(name)
if res:
if live_files is None:
live_files = walk(bprt_dir())[0]
res["stale"] = _stale(res, live_files)
row["validation"] = res
out.append(row)
out.sort(key=lambda r: r["modified"], reverse=True)
return out
def _snapshot(files):
"""{relpath: [size, mtime_ns]} of a walk(), as a validation records it."""
return {rel: [st.st_size, st.st_mtime_ns] for rel, st in files.items()}
def _live_moved(saved, files):
"""What differs between a recorded snapshot of the live BPRT and its
files now, or ''. Any file added, removed or changed there (an MCL upload
on /enrollment, an edit over SMB) means the validated plan, which decides
what the new folder keeps, is out of date."""
for rel in sorted(set(saved) | set(files), key=str.lower):
if rel not in files:
return f"{rel} is gone from the live BPRT"
if rel not in saved:
return f"{rel} was added to the live BPRT"
st = files[rel]
if [st.st_size, st.st_mtime_ns] != list(saved[rel]):
return f"{rel} in the live BPRT changed"
return ""
def _stale(res, live_files=None):
"""Why a validation no longer holds (a file changed since), or ''.
live_files: a walk() of the live BPRT, when the caller has one."""
try:
folder = batch_dir(res["batch"])
except ValueError:
return "bad batch name"
if res.get("live_all") is not None:
if live_files is None:
live_files = walk(bprt_dir())[0]
why = _live_moved(res["live_all"], live_files)
if why:
return f"{why} since the batch was validated"
for path, (size, mtime) in (res.get("batch_stat") or {}).items():
try:
st = os.lstat(os.path.join(folder, *path.split("/")))
except OSError:
return f"{path} is gone from the batch"
if (st.st_size, st.st_mtime_ns) != (size, mtime):
return f"{path} in the batch changed since it was validated"
for rel, (size, mtime) in (res.get("live_stat") or {}).items():
try:
st = os.lstat(os.path.join(bprt_dir(), *rel.split("/")))
except OSError:
return f"{rel} is gone from the live BPRT"
if (st.st_size, st.st_mtime_ns) != (size, mtime):
return f"{rel} in the live BPRT changed since the batch was validated"
return ""
def _check_rel(path):
"""A relative path from sync.json, or ValueError: forward slashes, no
empty, '.' or '..' parts, nothing Windows cannot name."""
if not isinstance(path, str) or not path or path.startswith("/") or len(path) > 240:
raise ValueError(f"bad path {path!r}")
parts = path.split("/")
for p in parts:
if p in ("", ".", "..") or _WIN_BAD.search(p) or p != p.rstrip(" ."):
raise ValueError(f"bad path {path!r}")
return path
def _dest(vpath):
"""Name a vendor file gets in BPRT. Case-insensitive, as on the PCs: a
vendor select-pcconfig.ps1 next to our Select-PCConfig.ps1 would be one
file to Windows."""
for vendor, kept in VENDOR_RENAMED.items():
if vpath.lower() == vendor.lower():
return kept
return vpath
def _ppkg_names(names):
"""Top-level names the enroll scripts may treat as packages."""
return [n for n in names if "/" not in n and (n.lower().endswith(".ppkg") or mcl_ppkg.is_mcl_name(n))]
def _versions(names):
"""{prefix: file name} for the vendor-valid MCL packages among names."""
out = {}
for n in names:
if mcl_ppkg.is_mcl_name(n):
out.setdefault(mcl_ppkg.parse(n, strict=False)["prefix"], []).append(n)
return out
def check_packages(names, errors, warnings):
"""The vendor rules on the package names a folder will hold."""
pk = _ppkg_names(names)
mcl = [n for n in pk if mcl_ppkg.is_mcl_name(n)]
for n in pk:
if n not in mcl:
warnings.append(f"{n} does not match the vendor's package naming; the enroll scripts ignore it")
elif "_exp_xxxxxxxx_" not in n.lower():
warnings.append(f"{n} has no Exp_XXXXXXXX placeholder: a package already used on a PC, "
"the enroll script cannot inject a new token into it")
if not mcl:
errors.append("no MCL package (*_v<major>.<minor>.ppkg) in the new folder")
seen = set()
for n, others in sorted(mcl_ppkg._clashes(mcl).items()):
prefix = mcl_ppkg.parse(n, strict=False)["prefix"]
group = frozenset([n, *others])
if group in seen:
continue # the same pair, seen from its other package
seen.add(group)
errors.append(f"{prefix} would match {n} and {', '.join(others)}: the enroll scripts fail "
"when one tenant/environment/purpose prefix finds two packages")
return mcl
def validate(name, progress=None):
"""Check an incoming batch and work out what activating it would change.
Saves and returns the result (state 'valid' or 'invalid')."""
folder = batch_dir(name)
progress = progress or Progress()
res = {"batch": name, "validated": uc.now(), "state": "invalid", "errors": [],
"warnings": [], "sync": {}, "plan": [], "batch_stat": {}, "live_stat": {}}
errors, warnings = res["errors"], res["warnings"]
try:
_validate(folder, res, progress)
except ValueError as ex:
errors.append(str(ex))
if not errors:
res["state"] = "valid"
uc.write_json(os.path.join(_state("batches"), f"{name}.json"), res)
return res
def _validate(folder, res, progress):
errors, warnings = res["errors"], res["warnings"]
if not os.path.isdir(folder):
raise ValueError("the batch folder is gone")
sync = _read_sync(folder)
if sync is None:
raise ValueError("no sync.json in the batch: Sync-BPRT.ps1 uploads it last, so the upload "
"did not finish (or this folder was made by hand)")
res["sync"] = {k: sync.get(k) for k in ("computer", "user", "created", "source", "tool")}
listed = _list(sync.get("files"))
if not isinstance(listed, list) or not listed:
raise ValueError("sync.json lists no files")
want, seen_lc = {}, {}
for row in listed:
if not isinstance(row, dict):
raise ValueError("sync.json: every file entry must be an object")
path = _check_rel(row.get("path"))
if path.lower() in seen_lc:
raise ValueError(f"sync.json lists {path} twice (names differing only by case "
"are one file on Windows)")
seen_lc[path.lower()] = path
try:
size = int(row.get("size"))
except (TypeError, ValueError):
raise ValueError(f"sync.json: {path} has no size") from None
sha = str(row.get("sha256") or "").upper()
if not re.fullmatch(r"[0-9A-F]{64}", sha):
raise ValueError(f"sync.json: {path} has no sha256")
want[path] = {"size": size, "sha256": sha}
for e in _list(sync.get("parse_errors")):
if isinstance(e, dict):
errors.append(f"{e.get('path')}: PowerShell could not parse it on the work PC "
f"({str(e.get('error', ''))[:200]}); the MediaCreator cache may be corrupt")
claimed = set(x for x in _list(sync.get("uploaded")) if isinstance(x, str))
have, _ = walk(folder)
have.pop("sync.json", None)
root = bprt_dir()
if os.path.islink(root) or not os.path.isdir(root):
raise ValueError(f"{root} is missing or not a folder")
live, skipped = walk(root)
res["live_all"] = _snapshot(live)
view = vendor_view(root, live)
# Windows names are case-insensitive: the work PC sees a file the vendor
# renamed by case only as unchanged and does not upload it, so a listed
# path finds its live file case-insensitively (exact spelling first).
view_lc = {vp.lower(): vp for vp in sorted(view, reverse=True)}
want_lc = _lc(want)
cache = HashCache()
def live_match(path):
return path if path in view else view_lc.get(path.lower())
# Hash what was uploaded and what in the live folder is not cached yet.
todo_live = [rel for vp, rel in view.items() if vp.lower() in want_lc and not cache.get(rel, live[rel])]
progress.start(sum(st.st_size for st in have.values())
+ sum(live[r].st_size for r in todo_live), len(have) + len(todo_live))
got = {}
for path in sorted(have):
progress.current(path)
st = have[path]
sha, nobom = hash_file(os.path.join(folder, *path.split("/")), path, progress.add)
got[path] = sha
res["batch_stat"][path] = [st.st_size, st.st_mtime_ns]
if path in want and sha == want[path]["sha256"]:
# Pre-seed the cache under the name it will have in BPRT.
cache.put(_dest(path), st, sha, nobom)
for path in sorted(set(have) - set(want)):
warnings.append(f"{path} was uploaded but is not in sync.json; it is ignored")
for path in sorted(claimed - set(have)):
if path in want:
errors.append(f"{path}: sync.json says it was uploaded but it is not in the batch")
plan, diff = [], {"new": [], "changed": [], "unchanged": [], "removed": []}
matched = set()
for path in sorted(want, key=str.lower):
w = want[path]
dest = _dest(path)
vp = live_match(path)
if vp is not None:
matched.add(vp)
if vp != path:
warnings.append(f"{vp} is now spelled {path} (a change of case only); the new BPRT "
"uses the new spelling")
if path in have:
if have[path].st_size != w["size"] or got[path] != w["sha256"]:
errors.append(f"{path}: hash mismatch (uploaded {have[path].st_size} bytes, sha256 "
f"{got[path][:12]}..., sync.json says {w['size']} bytes, "
f"{w['sha256'][:12]}...); upload it again")
continue
item = {"path": path, "dest": dest, "source": "incoming", "size": w["size"]}
if _is_script(path) and _bom_less_non_ascii(os.path.join(folder, *path.split("/"))):
item["bom_fix"] = True
if dest == VENDOR_RENAMED.get("Select-PCConfig.ps1") and is_shim(
os.path.join(folder, *path.split("/"))):
errors.append(f"{path} in the batch is our shim, not the vendor's dialog: kept as "
f"{dest} it would call itself. Upload the vendor folder, not the server's")
plan.append(item)
rel = view.get(vp) if vp else None
if rel is None:
diff["new"].append(path)
else:
progress.current(path)
e = cache.hash(root, rel, live[rel], progress.add)
res["live_stat"][rel] = [live[rel].st_size, live[rel].st_mtime_ns]
diff["unchanged" if _same(e, w["sha256"]) else "changed"].append(path)
else:
rel = view.get(vp) if vp else None
if rel is None:
errors.append(f"{path} was not uploaded and is not in the live BPRT")
continue
progress.current(path)
e = cache.hash(root, rel, live[rel], progress.add)
if not _same(e, w["sha256"]):
errors.append(f"{path} was not uploaded, but the live copy differs "
f"(live {e['sha256'][:12]}..., sync.json {w['sha256'][:12]}...); "
"run Sync-BPRT.ps1 again")
continue
res["live_stat"][rel] = [live[rel].st_size, live[rel].st_mtime_ns]
plan.append({"path": path, "dest": dest, "source": "live", "from": rel, "size": w["size"]})
diff["unchanged"].append(path)
diff["removed"] = sorted((vp for vp in view if vp not in matched), key=str.lower)
cache.save()
for s in skipped:
warnings.append(f"{s} in the live BPRT is a symlink or not a regular file; the new BPRT "
"will not have it (it stays in the archive)")
# The vendor rules and the files the flow calls by path.
check_packages(list(want), errors, warnings)
for path in sorted(have):
if path in want and path.lower().endswith(".ppkg"):
with open(os.path.join(folder, *path.split("/")), "rb") as f:
if f.read(len(mcl_ppkg.WIM_MAGIC)) != mcl_ppkg.WIM_MAGIC:
errors.append(f"{path} is not a provisioning package (no WIM header)")
for r in REQUIRED:
if r.lower() not in want_lc:
errors.append(f"{r} is missing: the bulk-enroll flow calls it by path")
for name in OVERLAY:
if name.lower() in _lc(want) and name not in VENDOR_RENAMED:
warnings.append(f"the vendor now ships {name}; the site copy replaces it as before")
for kept in VENDOR_RENAMED.values():
if kept.lower() in _lc(want):
errors.append(f"sync.json lists {kept}, the name the vendor's copy is kept under here")
srcs = overlay_sources(root, live)
if "Select-PCConfig.ps1" not in srcs:
errors.append("our Select-PCConfig.ps1 shim is not in the live BPRT (and no repo copy is "
"here): copy playbook/shopfloor-setup/BPRT/Select-PCConfig.ps1 there first")
for name in OVERLAY_REQUIRED:
if name not in srcs:
warnings.append(f"{name} is not in the live BPRT, so the new one will not have it "
"either: standard and engineer specialize (Order 39) calls it")
for path in diff["removed"]:
if path.lower().endswith(".zip"):
warnings.append(f"{path} is no longer shipped; MCL packages that install from it would fail")
old_pk = _versions(view)
new_pk = _versions(want)
ppkg = []
for prefix in sorted(set(old_pk) | set(new_pk), key=str.lower):
olds, news = old_pk.get(prefix, []), new_pk.get(prefix, [])
if sorted(olds) != sorted(news):
ppkg.append({"prefix": prefix,
"old": [{"name": n, "version": mcl_ppkg.parse(n, strict=False)["version"]} for n in olds],
"new": [{"name": n, "version": mcl_ppkg.parse(n, strict=False)["version"]} for n in news]})
zips = []
for path in diff["new"] + diff["changed"]:
if path.lower().endswith(".zip"):
vp = live_match(path)
rel = view[vp] if vp else None
zips.append({"path": path, "old_size": live[rel].st_size if rel else None,
"new_size": want[path]["size"]})
res["plan"] = plan
res["diff"] = diff
res["counts"] = {k: len(v) for k, v in diff.items()}
res["ppkg"] = ppkg
res["zips"] = zips
res["bom_fix"] = [i["path"] for i in plan if i.get("bom_fix")]
res["upload_bytes"] = sum(st.st_size for st in have.values())
res["total_bytes"] = sum(w["size"] for w in want.values())
# --- activation --------------------------------------------------------------
def _link(src, dst):
"""Hard link src at dst; a copy when the filesystem will not link."""
try:
os.link(src, dst)
except OSError as ex:
if ex.errno not in (errno.EXDEV, errno.EPERM, errno.EMLINK, errno.ENOTSUP, errno.EACCES):
raise
shutil.copy2(src, dst)
def _makedirs(root, rel_dir, live_root):
"""Create root/rel_dir level by level with the owner and mode of the same
folder in the live BPRT (else of BPRT itself)."""
if not rel_dir:
return
cur, live_cur = root, live_root
for part in rel_dir.split("/"):
cur = os.path.join(cur, part)
live_cur = os.path.join(live_cur, part)
if not os.path.isdir(cur):
os.mkdir(cur)
try:
mode = stat.S_IMODE(os.stat(live_cur).st_mode)
except OSError:
mode = _owner()[2] or 0o755
_match_owner(cur, mode)
def _file_mode(live_files, rel):
"""Mode for a new file: the one it replaces, else the most common one in
the live folder."""
st = live_files.get(rel)
if st:
return stat.S_IMODE(st.st_mode)
modes = Counter(stat.S_IMODE(s.st_mode) for s in live_files.values())
return modes.most_common(1)[0][0] if modes else _DEFAULT_FILE_MODE
def _unique(root, base):
path = os.path.join(root, base)
n = 1
while os.path.exists(path):
n += 1
path = os.path.join(root, f"{base}-{n}")
return path
def recover():
"""Finish or undo a swap a crash interrupted between its two renames, so
BPRT is never left missing. Returns what was done, or ''. Takes the lock:
while a job or rollback holds it, swap.json describes a swap in progress
(BPRT may be missing for a moment), not a crashed one, so it is left
alone; the holder cleans up itself."""
try:
with _Lock():
return _recover()
except _Busy:
return ""
def _recover():
"""recover() for a caller that already holds the lock."""
intent = uc.read_json(_path("swap.json"), None)
if not intent:
return ""
live = bprt_dir()
done = ""
if not os.path.exists(live):
if intent.get("new") and os.path.isdir(intent["new"]):
os.rename(intent["new"], live)
done = f"finished an interrupted swap: {os.path.basename(intent['new'])} is now BPRT"
elif intent.get("archive") and os.path.isdir(intent["archive"]):
os.rename(intent["archive"], live)
done = f"undid an interrupted swap: {os.path.basename(intent['archive'])} is BPRT again"
try:
os.remove(_path("swap.json"))
except OSError:
pass
if done:
_audit("BPRT_RECOVER", done)
return done
def _swap(src):
"""BPRT to BPRT-archive/full-<ts>, then src to BPRT. Returns the archive
path. SIGTERM (systemctl stop) waits until both renames are done."""
live = bprt_dir()
os.makedirs(archive_root(), exist_ok=True)
dest = _unique(archive_root(), f"full-{_stamp()}")
uc.write_json(_path("swap.json"), {"new": src, "archive": dest, "at": uc.now()})
old = None
try:
old = signal.signal(signal.SIGTERM, signal.SIG_IGN)
except ValueError:
pass # not the main thread (the webapp's inline rollback)
try:
os.rename(live, dest)
try:
os.rename(src, live)
except OSError:
os.rename(dest, live)
raise
finally:
if old is not None:
signal.signal(signal.SIGTERM, old)
if os.path.isdir(live):
try:
os.remove(_path("swap.json"))
except OSError:
pass
# else both renames failed after the first: swap.json stays, so
# recover() puts BPRT back on the next page load or job.
return dest
def activate(name, progress=None):
"""Build BPRT.new from the validated batch and swap it in."""
progress = progress or Progress()
with _Lock():
_recover()
res = validation(name)
if not res or res.get("state") != "valid":
raise RuntimeError("this batch is not validated")
folder = batch_dir(name)
live = bprt_dir()
if os.path.islink(live) or not os.path.isdir(live):
raise RuntimeError(f"{live} is missing or not a folder")
live_files, _ = walk(live)
why = _stale(res, live_files)
if why:
raise RuntimeError(f"{why}; validate it again")
srcs = overlay_sources(live, live_files)
if "Select-PCConfig.ps1" not in srcs:
raise RuntimeError("our Select-PCConfig.ps1 shim is not in the live BPRT")
new = new_dir()
if os.path.lexists(new):
shutil.rmtree(new) # left by a failed run; never live
os.mkdir(new)
_match_owner(new, _owner()[2] or 0o755)
try:
progress.step("Building BPRT.new")
progress.start(len(res["plan"]))
for item in res["plan"]:
dest_rel = item["dest"]
dest = os.path.join(new, *dest_rel.split("/"))
_makedirs(new, dest_rel.rpartition("/")[0], live)
if item["source"] == "live":
_link(os.path.join(live, *item["from"].split("/")), dest)
else:
src = os.path.join(folder, *item["path"].split("/"))
if item.get("bom_fix"):
with open(src, "rb") as f:
data = f.read()
with open(dest, "wb") as f:
f.write(BOM + data)
shutil.copystat(src, dest)
else:
_link(src, dest)
_match_owner(dest, _file_mode(live_files, dest_rel))
if item["source"] == "live":
expect = res["live_stat"][item["from"]][0] # BOM-fixed earlier: 3 bytes more
else:
expect = item["size"] + (len(BOM) if item.get("bom_fix") else 0)
if os.path.getsize(dest) != expect:
raise RuntimeError(f"{dest_rel} has the wrong size in BPRT.new")
progress.add(1)
progress.step("Applying the site overlay")
top = os.listdir(new)
for oname, src in srcs.items():
dest = os.path.join(new, oname)
for n in top:
if n.lower() == oname.lower():
os.remove(os.path.join(new, n)) # the vendor shipped one (any case); ours wins
if src.startswith(live + os.sep):
_link(src, dest)
else:
shutil.copy2(src, dest) # the repo copy of our shim
_match_owner(dest, _file_mode(live_files, oname))
# Last look at the folder that is about to go live.
names, _ = walk(new)
errors = []
check_packages(list(names), errors, [])
missing = [r for r in REQUIRED if _dest(r).lower() not in _lc(names)]
if missing:
errors.append(f"missing {', '.join(missing)}")
if errors:
raise RuntimeError("BPRT.new failed its final check: " + "; ".join(errors))
# An MCL upload on /enrollment (or an SMB edit) while BPRT.new
# was built would be lost with the old folder: check again.
why = _live_moved(_snapshot(live_files), walk(live)[0])
if why:
raise RuntimeError(f"{why} while BPRT.new was built; nothing was replaced, "
"validate the batch again")
progress.step("Swapping BPRT.new in")
archive = _swap(new)
except BaseException:
if os.path.isdir(live): # never drop BPRT.new while it is the only full copy
shutil.rmtree(new, ignore_errors=True)
raise
progress.step("Recording")
entry = {"id": _stamp(), "at": uc.now(), "kind": "activate", "batch": name,
"archive": os.path.basename(archive), "sync": res.get("sync", {}),
"counts": res.get("counts", {}), "ppkg": res.get("ppkg", []),
"bom_fix": res.get("bom_fix", [])}
_add_history(entry)
shutil.rmtree(folder, ignore_errors=True)
try:
os.remove(os.path.join(_state("batches"), f"{name}.json"))
except OSError:
pass
try:
manifest(build=True) # seeds the cache for the BOM-fixed files
except Exception:
pass
_audit("BPRT_ACTIVATE", f"{name}: {res['counts']}; previous BPRT in {entry['archive']}")
return entry
# --- archives and rollback ---------------------------------------------------
def archives():
"""Full BPRT copies in BPRT-archive, newest first, with the bytes only
they hold (files not hard-linked into the live BPRT)."""
root = archive_root()
out = []
if not os.path.isdir(root):
return out
live_inodes = {(st.st_dev, st.st_ino) for st in walk(bprt_dir())[0].values()}
for name in os.listdir(root):
folder = os.path.join(root, name)
if not _ARCHIVE.match(name) or not os.path.isdir(folder) or os.path.islink(folder):
continue
files, _ = walk(folder)
view = vendor_view(folder, files)
out.append({
"name": name, "count": len(files),
"bytes": sum(st.st_size for st in files.values()),
"own_bytes": sum(st.st_size for st in files.values()
if (st.st_dev, st.st_ino) not in live_inodes),
"modified": os.stat(folder).st_mtime,
"packages": sorted(n for n in files if mcl_ppkg.is_mcl_name(n)),
"missing_required": [r for r in REQUIRED if r not in view],
})
out.sort(key=lambda a: a["name"], reverse=True)
return out
def _check_archive(name):
if not _ARCHIVE.match(str(name or "")):
raise ValueError(f"{name or '(blank)'} is not a BPRT archive")
folder = os.path.join(archive_root(), name)
if not os.path.isdir(folder) or os.path.islink(folder):
raise ValueError(f"{name} is not in BPRT-archive")
return folder
def rollback(name):
"""Make an archived BPRT live again; the current one is archived first,
so a rollback can itself be rolled back."""
folder = _check_archive(name)
if busy():
raise RuntimeError(f"a BPRT job ({busy()}) is running")
with _Lock():
_recover()
files, _ = walk(folder)
errors = []
check_packages(list(files), errors, [])
if errors:
raise ValueError("; ".join(errors))
archive = _swap(folder)
entry = {"id": _stamp(), "at": uc.now(), "kind": "rollback", "restored": name,
"archive": os.path.basename(archive)}
_add_history(entry)
return entry
def delete_archive(name):
folder = _check_archive(name)
if busy():
raise RuntimeError(f"a BPRT job ({busy()}) is running")
with _Lock():
shutil.rmtree(folder)
return name
def delete_batch(name):
folder = os.path.join(incoming_root(), str(name or ""))
base = name[:-5] if str(name).endswith(".part") else name
check_batch(base)
if not os.path.isdir(folder) or os.path.islink(folder):
raise ValueError(f"{name} is not an incoming batch")
st = job_state()
if st.get("running") and st.get("arg") == base:
raise RuntimeError(f"the {st.get('kind')} job is using this batch")
shutil.rmtree(folder)
if base == name: # X.part is an upload; the validation of a finished X stays
try:
os.remove(os.path.join(_state("batches"), f"{base}.json"))
except OSError:
pass
return name
def history():
return uc.read_json(_path("history.json"), []) or []
def _add_history(entry):
hist = history()
hist.insert(0, entry)
uc.write_json(_path("history.json"), hist[:100])
# --- zip upload fallback -----------------------------------------------------
def clean_zip_name(name):
name = os.path.basename(str(name or "").replace("\\", "/")).strip()
if not name.lower().endswith(".zip") or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9 ._()+-]{0,150}", name):
raise ValueError("upload a .zip file (letters, digits, spaces, . _ - ( ) + in the name)")
return name
def save_zip(stream, name, length=None):
"""Stream an uploaded zip to BPRT-incoming/.uploads (same filesystem as
BPRT, never in memory). Returns (name, bytes)."""
name = clean_zip_name(name)
ensure_incoming()
folder = uploads_dir()
os.makedirs(folder, exist_ok=True)
free = shutil.disk_usage(folder).free
if length and length * 2 > free - (1 << 30):
raise RuntimeError(f"not enough disk space: the zip and its unpacked files need about "
f"{2 * length // (1 << 20)} MB, {free // (1 << 20)} MB free")
part = os.path.join(folder, f".{name}.{os.getpid()}.part")
written = 0
try:
with open(part, "wb") as f:
while True:
block = stream.read(1 << 20)
if not block:
break
f.write(block)
written += len(block)
if length and written != length:
raise RuntimeError(f"upload incomplete: {written} of {length} bytes")
if not written:
raise RuntimeError("empty upload")
if not zipfile.is_zipfile(part):
raise ValueError(f"{name} is not a zip file")
os.replace(part, os.path.join(folder, name))
finally:
if os.path.exists(part):
os.remove(part)
return name, written
def uploaded_zips():
folder = uploads_dir()
if not os.path.isdir(folder):
return []
out = []
for n in sorted(os.listdir(folder)):
p = os.path.join(folder, n)
if n.lower().endswith(".zip") and not n.startswith(".") and os.path.isfile(p):
st = os.stat(p)
out.append({"name": n, "size": st.st_size, "modified": st.st_mtime})
return out
def delete_zip(name):
path = os.path.join(uploads_dir(), clean_zip_name(name))
if not os.path.isfile(path):
raise ValueError(f"{name} is not an uploaded zip")
os.remove(path)
def _zip_members(zf):
"""[(ZipInfo, relpath)] of the files in a zip, any single top folder
(e.g. BPRT/) stripped; ValueError on unsafe names."""
rows = []
for info in zf.infolist():
if info.is_dir():
continue
name = info.filename.replace("\\", "/")
if name.startswith("__MACOSX/") or name.rsplit("/", 1)[-1] in ("Thumbs.db", ".DS_Store"):
continue
if name.startswith("/") or re.match(r"^[A-Za-z]:", name):
raise ValueError(f"zip entry {info.filename!r} has an absolute path")
rows.append([info, name])
tops = {r[1].split("/", 1)[0] for r in rows}
if len(tops) == 1 and all("/" in r[1] for r in rows):
cut = len(next(iter(tops))) + 1
for r in rows:
r[1] = r[1][cut:]
for r in rows:
_check_rel(r[1])
return [(info, rel) for info, rel in rows]
def unzip(name, progress=None):
"""Unpack an uploaded zip into a new incoming batch. A zip made by
Sync-BPRT.ps1 -ZipOut carries its sync.json; any other zip is taken as the
complete vendor folder and gets a sync.json listing everything in it."""
progress = progress or Progress()
name = clean_zip_name(name)
src = os.path.join(uploads_dir(), name)
if not os.path.isfile(src):
raise ValueError(f"{name} is not an uploaded zip")
base = f"{_stamp()}-zip"
final = _unique(incoming_root(), base)
part = final + ".part"
os.makedirs(part)
try:
with zipfile.ZipFile(src) as zf:
members = _zip_members(zf)
if not members:
raise ValueError(f"{name} holds no files")
need = sum(i.file_size for i, _ in members)
free = shutil.disk_usage(incoming_root()).free
if need > free - (1 << 30): # keep 1 GB for the share
raise RuntimeError(f"not enough disk space: {name} unpacks to {need // (1 << 20)} MB, "
f"{free // (1 << 20)} MB free")
progress.start(sum(i.file_size for i, _ in members), len(members))
listed = []
for info, rel in members:
progress.current(rel)
dest = os.path.join(part, *rel.split("/"))
os.makedirs(os.path.dirname(dest), exist_ok=True)
h = hashlib.sha256()
with zf.open(info) as fin, open(dest, "wb") as fout:
for block in iter(lambda: fin.read(1 << 22), b""):
fout.write(block)
h.update(block)
progress.add(len(block))
if rel != "sync.json":
listed.append({"path": rel, "size": info.file_size, "sha256": h.hexdigest().upper()})
if not os.path.exists(os.path.join(part, "sync.json")):
uc.write_json(os.path.join(part, "sync.json"), {
"format": 1, "tool": "webapp zip upload", "source": name,
"computer": "", "user": "", "created": uc.now(),
"files": listed, "uploaded": [r["path"] for r in listed],
})
os.rename(part, final)
except BaseException:
shutil.rmtree(part, ignore_errors=True)
raise
os.remove(src) # unpacked; the zip would only double the space used
_audit("BPRT_UNZIP", f"{name} into {os.path.basename(final)}")
return os.path.basename(final)
# --- imaging in progress -----------------------------------------------------
def _epoch(iso):
try:
dt = datetime.fromisoformat(str(iso))
except (TypeError, ValueError):
return None
if not dt.tzinfo:
dt = dt.replace(tzinfo=timezone.utc)
return dt.timestamp()
def active_imaging():
"""Imaging sessions still running (pushed or inferred from the logs in the
last few hours): each of those PCs copies BPRT while it images."""
try:
from services import imaging_status
sessions = imaging_status.list_sessions()
window = imaging_status.ACTIVE_WINDOW_HOURS * 3600
except Exception:
return []
cutoff = time.time() - window
out = []
for s in sessions:
if s.get("status") in ("succeeded", "failed"):
continue
ts = _epoch(s.get("last_updated"))
if ts is None or ts < cutoff:
continue
out.append({"id": s.get("serial") or s.get("mac") or s.get("ip") or "?",
"hostname": s.get("hostname") or "", "stage": s.get("current_stage") or "",
"last_updated": s.get("last_updated")})
return out
def sync_script():
"""Path of scripts/windows/Sync-BPRT.ps1 when the webapp can serve it."""
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
for p in (os.environ.get("BPRT_SYNC_SCRIPT", ""),
os.path.join(here, "static", "Sync-BPRT.ps1"),
os.path.join(here, "..", "scripts", "windows", "Sync-BPRT.ps1")):
if p and os.path.isfile(p):
return os.path.abspath(p)
return None
def _audit(action, detail):
try:
from services.audit import audit
audit(action, detail)
except Exception:
pass # audit log not writable (local tests): history.json still has it
# --- job entry point ---------------------------------------------------------
def main(argv):
if not argv or argv[0] not in ("manifest", "validate", "activate", "unzip"):
print("usage: python3 -m services.bprt_sync manifest | validate BATCH | activate BATCH | unzip NAME",
file=sys.stderr)
return 2
print(run_job(argv[0], argv[1] if len(argv) > 1 else ""))
return 0
if __name__ == "__main__":
sys.exit(main(sys.argv[1:]))