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:
@@ -0,0 +1,48 @@
|
|||||||
|
# Register-BulkEnrollTask-Standard.ps1 - register the BulkEnrollmentOrchestrator
|
||||||
|
# logon task for the gea-standard and gea-engineer images.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A FILE AND NOT AN INLINE COMMAND
|
||||||
|
# This ran inline as "powershell.exe -Command ..." in the unattend's specialize
|
||||||
|
# pass at RunSynchronousCommand Order 39. RunSynchronousCommand/Path is capped at
|
||||||
|
# 259 characters by the unattend schema; the inline form was 604, so Windows
|
||||||
|
# Setup rejected the ENTIRE answer file:
|
||||||
|
#
|
||||||
|
# /settings/RunSynchronous/RunSynchronousCommand/[Order="39"]/Path
|
||||||
|
# Description = Value is invalid. hrResult = 0x80220005 pass = specialize
|
||||||
|
#
|
||||||
|
# One over-length value invalidates every pass, so the machine stops at a dialog
|
||||||
|
# with nothing configured.
|
||||||
|
#
|
||||||
|
# WHY THERE ARE TWO OF THESE SCRIPTS
|
||||||
|
# gea-shopfloor uses Register-BulkEnrollTask.ps1, which launches the orchestrator
|
||||||
|
# as a PowerShell script with -ManualFallback: shopfloor bays are vanilla by
|
||||||
|
# design, so the orchestrator skips BPRT injection and the provisioning package.
|
||||||
|
# gea-standard and gea-engineer enrol normally and call the .cmd wrapper instead.
|
||||||
|
# All three image types share ONE Deploy\Applications\BPRT directory (a symlink
|
||||||
|
# to _shared\Applications\BPRT), so the two variants need distinct filenames.
|
||||||
|
#
|
||||||
|
# PESetup's CopyPackages copies the whole of <media>\Deploy\Applications to the
|
||||||
|
# target before the image is applied, so this script is present at
|
||||||
|
# C:\Deploy\Applications\BPRT\ by the time specialize runs. See
|
||||||
|
# docs/PESETUP-INTERNALS.md.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$taskName = 'BulkEnrollmentOrchestrator'
|
||||||
|
$orchestrator = 'C:\Deploy\Applications\BPRT\Start-BulkEnrollOrchestrator.cmd'
|
||||||
|
|
||||||
|
# Self-deleting task: schtasks /Delete drops the registration, then the
|
||||||
|
# orchestrator runs. cmd's & chains them so the delete happens even if the
|
||||||
|
# orchestrator later reboots the machine.
|
||||||
|
$argument = '/c schtasks /Delete /TN {0} /F & "{1}"' -f $taskName, $orchestrator
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $argument
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||||
|
# S-1-5-32-544 = BUILTIN\Administrators. GroupId + Highest so it runs elevated
|
||||||
|
# for whichever admin logs on first, without naming an account.
|
||||||
|
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-544' -RunLevel Highest
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 3)
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
|
||||||
|
|
||||||
|
Write-Host "Registered scheduled task '$taskName'."
|
||||||
42
playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1
Normal file
42
playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# Register-BulkEnrollTask.ps1 - register the BulkEnrollmentOrchestrator logon task.
|
||||||
|
#
|
||||||
|
# WHY THIS IS A FILE AND NOT AN INLINE COMMAND
|
||||||
|
# This ran as an inline "powershell.exe -Command ..." in the unattend's
|
||||||
|
# specialize pass, at RunSynchronousCommand Order 16. The unattend schema caps
|
||||||
|
# RunSynchronousCommand/Path at 259 characters; the inline form had grown to 676,
|
||||||
|
# so Windows Setup rejected the WHOLE answer file:
|
||||||
|
#
|
||||||
|
# /settings/RunSynchronous/RunSynchronousCommand/[Order="16"]/Path
|
||||||
|
# Description = Value is invalid. hrResult = 0x80220005 pass = specialize
|
||||||
|
# "Windows could not parse or process unattend answer file ... The answer file
|
||||||
|
# is invalid."
|
||||||
|
#
|
||||||
|
# One over-length value blocks every pass, so the machine stops at a dialog with
|
||||||
|
# nothing configured. Every shopfloor build from 2026-08-05 evening onward failed
|
||||||
|
# this way. Keep the unattend's Path short and put logic here.
|
||||||
|
#
|
||||||
|
# PESetup's CopyPackages copies the whole of <media>\Deploy\Applications to the
|
||||||
|
# target before the image is applied, so this script is present at
|
||||||
|
# C:\Deploy\Applications\BPRT\ by the time specialize runs. See
|
||||||
|
# docs/PESETUP-INTERNALS.md.
|
||||||
|
|
||||||
|
$ErrorActionPreference = 'Stop'
|
||||||
|
|
||||||
|
$taskName = 'BulkEnrollmentOrchestrator'
|
||||||
|
$orchestrator = 'C:\Deploy\Applications\BPRT\Start-BulkEnrollOrchestrator.ps1'
|
||||||
|
|
||||||
|
# Self-deleting task: schtasks /Delete removes the registration, then the
|
||||||
|
# orchestrator runs. cmd's & chains them so the delete happens even if the
|
||||||
|
# orchestrator later reboots the machine.
|
||||||
|
$argument = '/c schtasks /Delete /TN {0} /F & powershell.exe -NoProfile -ExecutionPolicy Bypass -File "{1}" -ManualFallback' -f $taskName, $orchestrator
|
||||||
|
|
||||||
|
$action = New-ScheduledTaskAction -Execute 'cmd.exe' -Argument $argument
|
||||||
|
$trigger = New-ScheduledTaskTrigger -AtLogOn
|
||||||
|
# S-1-5-32-544 = BUILTIN\Administrators. GroupId + Highest so it runs elevated
|
||||||
|
# for whichever admin logs on first, without naming an account.
|
||||||
|
$principal = New-ScheduledTaskPrincipal -GroupId 'S-1-5-32-544' -RunLevel Highest
|
||||||
|
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -ExecutionTimeLimit (New-TimeSpan -Hours 3)
|
||||||
|
|
||||||
|
Register-ScheduledTask -TaskName $taskName -Action $action -Trigger $trigger -Principal $principal -Settings $settings -Force
|
||||||
|
|
||||||
|
Write-Host "Registered scheduled task '$taskName'."
|
||||||
@@ -15,6 +15,11 @@ ENROLLMENT_SHARE = os.environ.get("ENROLLMENT_SHARE", "/srv/samba/enrollment")
|
|||||||
ENROLLMENT_PPKG_DIR = os.environ.get(
|
ENROLLMENT_PPKG_DIR = os.environ.get(
|
||||||
"ENROLLMENT_PPKG_DIR", os.path.join(ENROLLMENT_SHARE, "ppkgs")
|
"ENROLLMENT_PPKG_DIR", os.path.join(ENROLLMENT_SHARE, "ppkgs")
|
||||||
)
|
)
|
||||||
|
# Our BPRT overlay scripts (repo playbook/shopfloor-setup/BPRT/), which the
|
||||||
|
# playbook syncs here with the rest of shopfloor-setup.
|
||||||
|
BPRT_OVERLAY_DIR = os.environ.get(
|
||||||
|
"BPRT_OVERLAY_DIR", os.path.join(ENROLLMENT_SHARE, "shopfloor-setup", "BPRT")
|
||||||
|
)
|
||||||
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/pxe/image-upload")
|
UPLOAD_DIR = os.environ.get("UPLOAD_DIR", "/home/pxe/image-upload")
|
||||||
SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
|
SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared")
|
||||||
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html")
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import zipfile
|
|||||||
from collections import Counter
|
from collections import Counter
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
|
import config
|
||||||
from services import mcl_ppkg
|
from services import mcl_ppkg
|
||||||
from services import updates_common as uc
|
from services import updates_common as uc
|
||||||
|
|
||||||
@@ -65,12 +66,14 @@ UNIT = "pxe-bprt-sync"
|
|||||||
# Select-PCConfig-vendor.ps1 (VENDOR_RENAMED);
|
# Select-PCConfig-vendor.ps1 (VENDOR_RENAMED);
|
||||||
# Register-BulkEnrollTask-Standard.ps1 registers the orchestrator; specialize Order 39
|
# Register-BulkEnrollTask-Standard.ps1 registers the orchestrator; specialize Order 39
|
||||||
# of playbook/FlatUnattendW10.xml and
|
# of playbook/FlatUnattendW10.xml and
|
||||||
# FlatUnattendW10-engineer.xml runs it. It exists
|
# FlatUnattendW10-engineer.xml runs it;
|
||||||
# only on the share (no repo copy);
|
|
||||||
# Register-BulkEnrollTask.ps1 the shopfloor -ManualFallback variant. Nothing
|
# Register-BulkEnrollTask.ps1 the shopfloor -ManualFallback variant. Nothing
|
||||||
# calls it since the shopfloor unattend dropped
|
# calls it since the shopfloor unattend dropped
|
||||||
# its Order 16 (2026-08-06); kept as it was.
|
# 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")
|
"Register-BulkEnrollTask.ps1")
|
||||||
# Vendor files the overlay displaces, and the name the vendor's copy keeps.
|
# Vendor files the overlay displaces, and the name the vendor's copy keeps.
|
||||||
VENDOR_RENAMED = {"Select-PCConfig.ps1": "Select-PCConfig-vendor.ps1"}
|
VENDOR_RENAMED = {"Select-PCConfig.ps1": "Select-PCConfig-vendor.ps1"}
|
||||||
@@ -332,22 +335,98 @@ def vendor_view(root, files):
|
|||||||
return out
|
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):
|
def overlay_sources(root, files):
|
||||||
"""{overlay name: absolute source path} found in a BPRT folder; our shim
|
"""{overlay name: absolute source path}: the copy in this BPRT folder,
|
||||||
falls back to the repo copy when the webapp runs from a checkout."""
|
else the first one in overlay_fallbacks(). A Select-PCConfig.ps1 counts
|
||||||
|
only when it is our shim."""
|
||||||
out = {}
|
out = {}
|
||||||
for name in OVERLAY:
|
for name in OVERLAY:
|
||||||
path = os.path.join(root, name)
|
path = os.path.join(root, name)
|
||||||
if name in files and (name not in VENDOR_RENAMED or is_shim(path)):
|
if name in files and (name not in VENDOR_RENAMED or is_shim(path)):
|
||||||
out[name] = path
|
out[name] = path
|
||||||
if "Select-PCConfig.ps1" not in out:
|
continue
|
||||||
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
for folder in overlay_fallbacks():
|
||||||
repo = os.path.join(here, "..", "playbook", "shopfloor-setup", "BPRT", "Select-PCConfig.ps1")
|
path = os.path.join(folder, name)
|
||||||
if is_shim(repo):
|
if os.path.isfile(path) and (name not in VENDOR_RENAMED or is_shim(path)):
|
||||||
out["Select-PCConfig.ps1"] = os.path.abspath(repo)
|
out[name] = path
|
||||||
|
break
|
||||||
return out
|
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):
|
def manifest(build=False, progress=None):
|
||||||
"""What manifest.json serves: every vendor file of the live BPRT with size
|
"""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
|
and sha256 (and sha256_nobom where we added a BOM). Returns None when a
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ def _replace_with_symlink(link_path, target_path):
|
|||||||
os.symlink(target_path, link_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
|
"""Copy src -> dst with rsync, SKIPPING files whose content already
|
||||||
matches and transferring only new or changed files.
|
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
|
set checksum=False for the faster size+mtime comparison. move=True
|
||||||
removes source files after a successful transfer (frees the upload dir).
|
removes source files after a successful transfer (frees the upload dir).
|
||||||
Existing dst files not present in src are left untouched (merge, not
|
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 = ["-a"] # recurse, preserve perms/times/symlinks
|
||||||
|
flags += [f"--exclude={pat}" for pat in exclude]
|
||||||
if checksum:
|
if checksum:
|
||||||
flags.append("--checksum") # compare by content hash, not size+mtime
|
flags.append("--checksum") # compare by content hash, not size+mtime
|
||||||
if move:
|
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
|
_transfer_tree = shutil.move if move else shutil.copytree
|
||||||
|
|
||||||
os.makedirs(dst_deploy, exist_ok=True)
|
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)
|
src_item = os.path.join(src_deploy, item)
|
||||||
dst_item = os.path.join(dst_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):
|
if not os.path.isdir(src_item):
|
||||||
_transfer(src_item, dst_item)
|
_transfer(src_item, dst_item)
|
||||||
continue
|
continue
|
||||||
@@ -103,6 +111,41 @@ def import_deploy(src_deploy, dst_deploy, target="", move=False):
|
|||||||
_transfer_tree(src_item, dst_item)
|
_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):
|
def allowed_import_source(source):
|
||||||
"""True if source is a USB mount or under the upload dir."""
|
"""True if source is a USB mount or under the upload dir."""
|
||||||
# Compare real paths: "<upload dir>/../.." passes a plain prefix test.
|
# Compare real paths: "<upload dir>/../.." passes a plain prefix test.
|
||||||
|
|||||||
146
webapp/tests/test_bprt_import.py
Normal file
146
webapp/tests/test_bprt_import.py
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
"""Image import and the shared BPRT folder.
|
||||||
|
|
||||||
|
Run from the webapp folder:
|
||||||
|
PYTHONPATH=.:<deps> python -m unittest tests.test_bprt_import -v
|
||||||
|
|
||||||
|
An MCL import carries Deploy/Applications/BPRT. rsync used to replace each
|
||||||
|
image's BPRT link with that plain vendor copy, which has no site overlay, so
|
||||||
|
standard/engineer enrollment lost Register-BulkEnrollTask-Standard.ps1. Now
|
||||||
|
the import seeds _shared BPRT on a fresh box, leaves an existing one alone,
|
||||||
|
and always restores the link. Every config path points into a temp folder.
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import unittest
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
_T = tempfile.mkdtemp(prefix="pxe-bprt-import-test-")
|
||||||
|
for _k, _sub in {"SAMBA_SHARE": "samba", "ENROLLMENT_SHARE": "enr", "UPDATES_DIR": "upd",
|
||||||
|
"UPLOAD_DIR": "upl"}.items():
|
||||||
|
os.environ.setdefault(_k, os.path.join(_T, _sub))
|
||||||
|
os.makedirs(os.environ[_k], exist_ok=True)
|
||||||
|
os.environ.setdefault("AUDIT_LOG", os.path.join(_T, "audit_log"))
|
||||||
|
|
||||||
|
WEBAPP = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
REPO_OVERLAY = os.path.join(WEBAPP, "..", "playbook", "shopfloor-setup", "BPRT")
|
||||||
|
sys.path.insert(0, WEBAPP)
|
||||||
|
|
||||||
|
import config # noqa: E402
|
||||||
|
from services import bprt_sync as BS # noqa: E402
|
||||||
|
from services import deploy # noqa: E402
|
||||||
|
|
||||||
|
VENDOR_SELECT = b"# vendor dialog\r\nShow-Dialog\r\n"
|
||||||
|
|
||||||
|
|
||||||
|
def write(path, data=b"x"):
|
||||||
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||||
|
with open(path, "wb") as f:
|
||||||
|
f.write(data)
|
||||||
|
|
||||||
|
|
||||||
|
def vendor_bprt(root, version=b"v1", skip=()):
|
||||||
|
for name in BS.REQUIRED:
|
||||||
|
if name in skip:
|
||||||
|
continue
|
||||||
|
write(os.path.join(root, name), VENDOR_SELECT if name == "Select-PCConfig.ps1" else version)
|
||||||
|
write(os.path.join(root, "Translations", "de.psd1"), b"@{}")
|
||||||
|
|
||||||
|
|
||||||
|
def read(path):
|
||||||
|
with open(path, "rb") as f:
|
||||||
|
return f.read()
|
||||||
|
|
||||||
|
|
||||||
|
class BprtImport(unittest.TestCase):
|
||||||
|
def setUp(self):
|
||||||
|
for d in (config.SAMBA_SHARE, config.UPLOAD_DIR, config.ENROLLMENT_SHARE, config.UPDATES_DIR):
|
||||||
|
shutil.rmtree(d, ignore_errors=True)
|
||||||
|
os.makedirs(d)
|
||||||
|
shutil.copytree(REPO_OVERLAY, config.BPRT_OVERLAY_DIR)
|
||||||
|
self.src = os.path.join(config.UPLOAD_DIR, "Deploy")
|
||||||
|
vendor_bprt(os.path.join(self.src, "Applications", "BPRT"))
|
||||||
|
write(os.path.join(self.src, "Applications", "extra", "app.msi"), b"msi")
|
||||||
|
write(os.path.join(self.src, "Control", "OperatingSystem.json"), b"{}")
|
||||||
|
self.dest = os.path.join(config.SAMBA_SHARE, "gea-standard", "Deploy")
|
||||||
|
self.live = BS.bprt_dir()
|
||||||
|
self.link = os.path.join(self.dest, "Applications", "BPRT")
|
||||||
|
|
||||||
|
def assert_linked(self):
|
||||||
|
self.assertTrue(os.path.islink(self.link), "image BPRT is not a link")
|
||||||
|
self.assertEqual(os.path.realpath(self.link), os.path.realpath(self.live))
|
||||||
|
|
||||||
|
def test_fresh_box_seeds_shared_bprt_with_overlay(self):
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard")
|
||||||
|
self.assertTrue(os.path.isdir(self.live) and not os.path.islink(self.live))
|
||||||
|
self.assertTrue(BS.is_shim(os.path.join(self.live, "Select-PCConfig.ps1")))
|
||||||
|
self.assertEqual(read(os.path.join(self.live, "Select-PCConfig-vendor.ps1")), VENDOR_SELECT)
|
||||||
|
for name in BS.OVERLAY:
|
||||||
|
self.assertEqual(read(os.path.join(self.live, name)),
|
||||||
|
read(os.path.join(REPO_OVERLAY, name)), name)
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(self.live, "Translations", "de.psd1")))
|
||||||
|
self.assert_linked()
|
||||||
|
self.assertEqual(read(os.path.join(self.dest, "Applications", "extra", "app.msi")), b"msi")
|
||||||
|
self.assertFalse(os.path.exists(BS.new_dir()))
|
||||||
|
|
||||||
|
def test_existing_bprt_is_kept_and_link_restored(self):
|
||||||
|
vendor_bprt(self.live, version=b"live")
|
||||||
|
shutil.copy2(os.path.join(REPO_OVERLAY, "Select-PCConfig.ps1"),
|
||||||
|
os.path.join(self.live, "Select-PCConfig.ps1"))
|
||||||
|
# what the old import left: a plain copy in place of the link
|
||||||
|
vendor_bprt(self.link, version=b"stale copy")
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard")
|
||||||
|
self.assertEqual(read(os.path.join(self.live, "WCD-Helpers.ps1")), b"live")
|
||||||
|
self.assertTrue(BS.is_shim(os.path.join(self.live, "Select-PCConfig.ps1")))
|
||||||
|
self.assert_linked()
|
||||||
|
|
||||||
|
def test_differently_cased_copy_is_replaced(self):
|
||||||
|
vendor_bprt(self.live, version=b"live")
|
||||||
|
vendor_bprt(os.path.join(self.dest, "Applications", "bprt"), version=b"stale")
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard")
|
||||||
|
self.assertEqual(sorted(n for n in os.listdir(os.path.join(self.dest, "Applications"))
|
||||||
|
if n.lower() == "bprt"), ["BPRT"])
|
||||||
|
self.assert_linked()
|
||||||
|
|
||||||
|
def test_fresh_box_without_overlay_refuses_before_anything_moves(self):
|
||||||
|
empty = os.path.join(_T, "no-overlay")
|
||||||
|
os.makedirs(empty, exist_ok=True)
|
||||||
|
with mock.patch.object(BS, "overlay_fallbacks", return_value=[empty]):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Register-BulkEnrollTask-Standard.ps1"):
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard", move=True)
|
||||||
|
self.assertFalse(os.path.lexists(self.live))
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dest, "Applications")))
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.dest, "Control")))
|
||||||
|
self.assertTrue(os.path.isfile(os.path.join(self.src, "Applications", "extra", "app.msi")))
|
||||||
|
|
||||||
|
def test_incomplete_vendor_bprt_refuses(self):
|
||||||
|
shutil.rmtree(os.path.join(self.src, "Applications", "BPRT"))
|
||||||
|
vendor_bprt(os.path.join(self.src, "Applications", "BPRT"), skip=("WCD-Helpers.ps1",))
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "WCD-Helpers.ps1"):
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard")
|
||||||
|
self.assertFalse(os.path.lexists(self.live))
|
||||||
|
|
||||||
|
def test_move_clears_the_upload_bprt(self):
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard", move=True)
|
||||||
|
self.assertFalse(os.path.exists(os.path.join(self.src, "Applications")))
|
||||||
|
self.assert_linked()
|
||||||
|
|
||||||
|
def test_overlay_sources_fill_in_register_scripts(self):
|
||||||
|
vendor_bprt(self.live)
|
||||||
|
files, _ = BS.walk(self.live)
|
||||||
|
srcs = BS.overlay_sources(self.live, files)
|
||||||
|
self.assertEqual(srcs["Register-BulkEnrollTask-Standard.ps1"],
|
||||||
|
os.path.join(config.BPRT_OVERLAY_DIR, "Register-BulkEnrollTask-Standard.ps1"))
|
||||||
|
self.assertEqual(os.path.dirname(srcs["Select-PCConfig.ps1"]), config.BPRT_OVERLAY_DIR)
|
||||||
|
|
||||||
|
def test_vendor_select_in_overlay_dir_is_not_our_shim(self):
|
||||||
|
os.remove(os.path.join(config.BPRT_OVERLAY_DIR, "Select-PCConfig.ps1"))
|
||||||
|
write(os.path.join(config.BPRT_OVERLAY_DIR, "Select-PCConfig.ps1"), VENDOR_SELECT)
|
||||||
|
with mock.patch.object(BS, "overlay_fallbacks", return_value=[config.BPRT_OVERLAY_DIR]):
|
||||||
|
with self.assertRaisesRegex(RuntimeError, "Select-PCConfig.ps1"):
|
||||||
|
deploy.import_deploy(self.src, self.dest, "gea-standard")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
Reference in New Issue
Block a user