Image import keeps the shared BPRT link and seeds BPRT on a fresh box

An MCL import merged Deploy/Applications with rsync -a, which replaced each
image's BPRT symlink with a plain copy of the vendor folder. That copy has
no site overlay, so standard/engineer enrollment lost
Register-BulkEnrollTask-Standard.ps1 (specialize Order 39).

Now Deploy/Applications is imported first and without BPRT:
- where _shared/Applications/BPRT exists it is left alone (the BPRT page
  keeps it current) and the image link is put back;
- on a box with none yet, the imported BPRT seeds it: vendor
  Select-PCConfig.ps1 kept as -vendor, our overlay added, BOM fix applied;
- the import refuses before anything moves when that folder would not work
  (vendor scripts missing, or no overlay copy to add).

The two Register scripts join Select-PCConfig.ps1 in the repo. The playbook
already syncs shopfloor-setup to the enrollment share, so
config.BPRT_OVERLAY_DIR finds all three there; overlay_sources now falls
back to it for every overlay file, not only the shim.
This commit is contained in:
cproudlock
2026-10-16 14:56:33 -04:00
parent 821a927365
commit d63cc5761b
6 changed files with 376 additions and 13 deletions

View File

@@ -50,6 +50,7 @@ import zipfile
from collections import Counter
from datetime import datetime, timezone
import config
from services import mcl_ppkg
from services import updates_common as uc
@@ -65,12 +66,14 @@ UNIT = "pxe-bprt-sync"
# 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);
# FlatUnattendW10-engineer.xml runs it;
# 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",
# All three are in the repo, playbook/shopfloor-setup/BPRT/; the playbook
# syncs them to config.BPRT_OVERLAY_DIR. A copy in the live BPRT wins; those
# copies fill in what it lacks (overlay_sources).
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"}
@@ -332,22 +335,98 @@ def vendor_view(root, files):
return out
def overlay_fallbacks():
"""Folders holding our overlay outside BPRT: the copy the playbook syncs
to the enrollment share, then the repo when the webapp runs from a
checkout."""
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
return [config.BPRT_OVERLAY_DIR,
os.path.abspath(os.path.join(here, "..", "playbook", "shopfloor-setup", "BPRT"))]
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."""
"""{overlay name: absolute source path}: the copy in this BPRT folder,
else the first one in overlay_fallbacks(). A Select-PCConfig.ps1 counts
only when it is our shim."""
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)
continue
for folder in overlay_fallbacks():
path = os.path.join(folder, name)
if os.path.isfile(path) and (name not in VENDOR_RENAMED or is_shim(path)):
out[name] = path
break
return out
def seed_from_import(src):
"""An image import brought a BPRT folder (src). On a box that has no
_shared/Applications/BPRT yet (a fresh install) it becomes that folder,
with the vendor's Select-PCConfig.ps1 kept as Select-PCConfig-vendor.ps1
and our overlay added, the way activate() builds one. Where BPRT exists
it is left alone: the BPRT page keeps it current. Returns "seeded" or
"kept"; raises before anything changed when the folder would not work."""
with _Lock():
_recover()
live = bprt_dir()
if os.path.isdir(live) and not os.path.islink(live):
return "kept"
if os.path.lexists(live):
raise RuntimeError(f"{live} exists but is not a folder; fix it on the server first")
files, _ = walk(src)
names = _lc(files)
missing = [r for r in REQUIRED if r.lower() not in names]
if missing:
raise RuntimeError("the imported BPRT folder lacks " + ", ".join(missing)
+ ": the bulk-enroll flow calls them by path")
srcs = overlay_sources(src, files)
lack = [n for n in ("Select-PCConfig.ps1",) + OVERLAY_REQUIRED if n not in srcs]
if lack:
raise RuntimeError(", ".join(lack) + " not found in " + config.BPRT_OVERLAY_DIR
+ ": run the playbook (it copies shopfloor-setup there), then import again")
new = new_dir()
if os.path.lexists(new):
shutil.rmtree(new) # left by a failed run; never live
os.makedirs(os.path.dirname(new), exist_ok=True)
try:
shutil.copytree(src, new, symlinks=False)
top = os.listdir(new)
for vendor, kept in VENDOR_RENAMED.items():
for n in top:
p = os.path.join(new, n)
if n.lower() == vendor.lower() and not is_shim(p):
os.replace(p, os.path.join(new, kept))
top = os.listdir(new)
for oname, s in srcs.items():
for n in top:
if n.lower() == oname.lower():
os.remove(os.path.join(new, n)) # the vendor shipped one (any case); ours wins
shutil.copy2(s, os.path.join(new, oname))
bom = []
for rel in walk(new)[0]:
path = os.path.join(new, *rel.split("/"))
if _is_script(rel) and _bom_less_non_ascii(path):
with open(path, "rb") as f:
data = f.read()
with open(path, "wb") as f:
f.write(BOM + data)
bom.append(rel)
for dirpath, dirs, fnames in os.walk(new):
_match_owner(dirpath, 0o755)
for n in fnames:
_match_owner(os.path.join(dirpath, n), _DEFAULT_FILE_MODE)
os.rename(new, live)
except BaseException:
shutil.rmtree(new, ignore_errors=True)
raise
_audit("BPRT_SEED", f"from image import {src}: {len(files)} files, overlay "
f"{', '.join(sorted(srcs))}, BOM added to {len(bom)}")
return "seeded"
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

View File

@@ -22,7 +22,7 @@ def _replace_with_symlink(link_path, target_path):
os.symlink(target_path, link_path)
def sync_tree(src, dst, move=False, checksum=True):
def sync_tree(src, dst, move=False, checksum=True, exclude=()):
"""Copy src -> dst with rsync, SKIPPING files whose content already
matches and transferring only new or changed files.
@@ -32,8 +32,10 @@ def sync_tree(src, dst, move=False, checksum=True):
set checksum=False for the faster size+mtime comparison. move=True
removes source files after a successful transfer (frees the upload dir).
Existing dst files not present in src are left untouched (merge, not
mirror - no --delete)."""
mirror - no --delete). exclude: rsync patterns left out ("/BPRT" is
the top-level BPRT only)."""
flags = ["-a"] # recurse, preserve perms/times/symlinks
flags += [f"--exclude={pat}" for pat in exclude]
if checksum:
flags.append("--checksum") # compare by content hash, not size+mtime
if move:
@@ -75,10 +77,16 @@ def import_deploy(src_deploy, dst_deploy, target="", move=False):
_transfer_tree = shutil.move if move else shutil.copytree
os.makedirs(dst_deploy, exist_ok=True)
for item in os.listdir(src_deploy):
# Applications first: its BPRT check can refuse the import before
# anything else has moved.
for item in sorted(os.listdir(src_deploy), key=lambda n: n != "Applications"):
src_item = os.path.join(src_deploy, item)
dst_item = os.path.join(dst_deploy, item)
if item == "Applications" and os.path.isdir(src_item):
_import_applications(src_item, dst_item, move=move)
continue
if not os.path.isdir(src_item):
_transfer(src_item, dst_item)
continue
@@ -103,6 +111,41 @@ def import_deploy(src_deploy, dst_deploy, target="", move=False):
_transfer_tree(src_item, dst_item)
def _import_applications(src_apps, dst_apps, move=False):
"""Deploy/Applications, except BPRT. Every image links its BPRT to
_shared/Applications/BPRT, which the BPRT page keeps current and which
carries our overlay (services/bprt_sync.py). rsync -a replaces that link
with a plain copy of the vendor's folder, without the overlay, and
standard/engineer enrollment then fails at specialize Order 39. So the
imported BPRT only seeds the shared folder on a box that has none yet,
and is otherwise left out; the link is put back either way."""
from services import bprt_sync
bprt = next((n for n in os.listdir(src_apps)
if n.lower() == "bprt" and os.path.isdir(os.path.join(src_apps, n))), None)
if bprt:
bprt_sync.seed_from_import(os.path.join(src_apps, bprt))
sync_tree(src_apps, dst_apps, move=move, exclude=[f"/{bprt}"] if bprt else ())
if bprt and move:
shutil.rmtree(os.path.join(src_apps, bprt), ignore_errors=True)
try:
os.rmdir(src_apps)
except OSError:
pass
shared = bprt_sync.bprt_dir()
if not os.path.isdir(shared) or os.path.islink(shared):
return
for n in os.listdir(dst_apps):
p = os.path.join(dst_apps, n)
if n.lower() == "bprt" and n != "BPRT":
if os.path.islink(p) or not os.path.isdir(p):
os.remove(p)
else:
shutil.rmtree(p)
link = os.path.join(dst_apps, "BPRT")
if not (os.path.islink(link) and os.path.realpath(link) == os.path.realpath(shared)):
_replace_with_symlink(link, shared)
def allowed_import_source(source):
"""True if source is a USB mount or under the upload dir."""
# Compare real paths: "<upload dir>/../.." passes a plain prefix test.