From d63cc5761b0ccccc0a3c5d7fece566cbfcea073e Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 16 Oct 2026 14:56:33 -0400 Subject: [PATCH] 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. --- .../BPRT/Register-BulkEnrollTask-Standard.ps1 | 48 ++++++ .../BPRT/Register-BulkEnrollTask.ps1 | 42 +++++ webapp/config.py | 5 + webapp/services/bprt_sync.py | 99 ++++++++++-- webapp/services/deploy.py | 49 +++++- webapp/tests/test_bprt_import.py | 146 ++++++++++++++++++ 6 files changed, 376 insertions(+), 13 deletions(-) create mode 100644 playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask-Standard.ps1 create mode 100644 playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1 create mode 100644 webapp/tests/test_bprt_import.py diff --git a/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask-Standard.ps1 b/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask-Standard.ps1 new file mode 100644 index 0000000..f144ff5 --- /dev/null +++ b/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask-Standard.ps1 @@ -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 \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'." diff --git a/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1 b/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1 new file mode 100644 index 0000000..9810ca4 --- /dev/null +++ b/playbook/shopfloor-setup/BPRT/Register-BulkEnrollTask.ps1 @@ -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 \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'." diff --git a/webapp/config.py b/webapp/config.py index 4040a35..de9abee 100644 --- a/webapp/config.py +++ b/webapp/config.py @@ -15,6 +15,11 @@ ENROLLMENT_SHARE = os.environ.get("ENROLLMENT_SHARE", "/srv/samba/enrollment") ENROLLMENT_PPKG_DIR = os.environ.get( "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") SHARED_DIR = os.path.join(SAMBA_SHARE, "_shared") WEB_ROOT = os.environ.get("WEB_ROOT", "/var/www/html") diff --git a/webapp/services/bprt_sync.py b/webapp/services/bprt_sync.py index c4bc1b3..0c3a9b0 100644 --- a/webapp/services/bprt_sync.py +++ b/webapp/services/bprt_sync.py @@ -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 diff --git a/webapp/services/deploy.py b/webapp/services/deploy.py index 87faf3f..5b3361e 100644 --- a/webapp/services/deploy.py +++ b/webapp/services/deploy.py @@ -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: "/../.." passes a plain prefix test. diff --git a/webapp/tests/test_bprt_import.py b/webapp/tests/test_bprt_import.py new file mode 100644 index 0000000..221bbf7 --- /dev/null +++ b/webapp/tests/test_bprt_import.py @@ -0,0 +1,146 @@ +"""Image import and the shared BPRT folder. + +Run from the webapp folder: + PYTHONPATH=.: 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()