Shop Floor PCs enroll with a provisioning package. Open Tools > Enrollment packages, click Upload SFLD package and upload the current GCCH_Prod_SFLD_v<version>.ppkg. The package you upload becomes the active one (shown on that page); upload a newer version the same way when one is released.
+
Part 4 - Site Content
+
The build folder carries no site content. Add these in the web interface; each one can be replaced later the same way.
+
+ Step 1
+
Enrollment package (Shop Floor)
+
Shop Floor PCs enroll with a provisioning package. Open Tools > Enrollment packages, click Upload SFLD package and upload the current GCCH_Prod_SFLD_v<version>.ppkg. The package you upload becomes the active one (shown on that page); upload a newer version the same way when one is released.
+
+
+
+ Step 2
+
Engineering Layer (Engineer)
+
Engineer PCs install the Engineering Layer at first logon. Without it they still image, but with no engineering applications. Open Images > Engineer, tab Engineering Layer, and upload one of:
+
+
the whole payload as a .zip, with the folders engineering_layer\ (holding Engineering_Install_v<version>.exe) and prereqs\ (VC++ and .NET 3.5) at its top, or inside one extra_engineering\ folder. Use this the first time.
+
only a newer Engineering_Install_v<version>.exe. It replaces the installer and keeps the prerequisites.
+
+
The page shows the installer version in use and the status Ready once both parts are present. The two previous uploads are kept under Previous versions, with a Restore button. The Engineer image must be imported (Part 3) first.
+
+
+
+ Step 3
+
Blancco site settings
+
Open Updates > Blancco releases and fill in Site settings:
+
+
Business location: your site, as it should appear on every erasure report.
+
Wi-Fi network (SSID) and Wi-Fi password: the internet-only Wi-Fi Blancco joins to license each erasure in the GE Blancco cloud. Usually INTERNETACCESS; the password differs between sites, so get yours from the site network team.
+
+
Click Save. The settings go into the active Blancco release at once and into every release staged later.
+
+
+
+ Step 4
+
Shop Floor apps (optional)
+
A Shop Floor PC gets the provisioning package and the ShopFloor auto-logon and lockdown, and nothing else. To install your own applications on it, see Adding Shop Floor Apps.
The site name stamped on Blancco erasure reports and the monthly CMMC evidence packs. Not set: Unnamed site. Letters, digits, spaces and . , - ( ) & / only.
Always
"West Jefferson"
+
site_name
The site name stamped on Blancco erasure reports and the monthly CMMC evidence packs. Not set: Unnamed site. Letters, digits, spaces and . , - ( ) & / only.
Always
"Example Site"
site_timezone
Time zone of the server clock, of every time in the web interface, and of the evidence-pack month boundaries.
If the site is not on US Eastern time
"America/Chicago"
-
clonezilla_smb_password
Password of the share Clonezilla backs up to and restores from. The boot menu carries it, so it protects the share on the imaging network only. 8-64 characters from A-Z a-z 0-9 . _ ~ -
Always (the default is the same at every site)
"WJ-Clone-2026"
+
clonezilla_smb_password
Password of the share Clonezilla backs up to and restores from. The boot menu carries it, so it protects the share on the imaging network only. 8-64 characters from A-Z a-z 0-9 . _ ~ -
Always (the default is the same at every site)
"ExSite-Clone-2026"
corp_admin_cidrs
The GE subnets allowed to reach the web interface (port 9009) and SSH. Not set: all of GE. The imaging network always has access.
Recommended once the server is on the GE network
["10.12.34.0/24"]
pxe_nic_mac corp_nic_mac
Pin the imaging port and the GE port by MAC address instead of picking the imaging port at install. Then both cables may be in from the start.
Only if the automatic pick is not wanted
"34:c8:d6:b1:10:10"
pxe_nic_allowed_switches
A managed imaging switch sends LLDP/CDP like a GE switch, and the server would refuse to serve on it. Its name (or MAC) here marks it as the imaging switch. Comma separated.
A Shop Floor PC installs two app lists, both empty when the server is installed:
+
+
List
When it installs
Use it for
+
preinstall.json
At first logon, in the same run as the provisioning package, before the PC restarts
Apps that must be present before the PC enrolls
+
postppkg.json
Once, after the restart that follows the provisioning package (as SYSTEM)
Everything else
+
+
Both are in the pre-install folder of the enrollment share. The first list shows as a step on Operate > Imaging floor; each PC keeps its logs in C:\Logs\PreInstall\install.log and C:\Logs\PostPpkg\install.log.
+
+
+ Step 1
+
Open the share
+
From a PC on the imaging switch, open \\172.16.9.1\enrollment\pre-install and sign in as pxe-upload (password pxe, the account Upload-Image.ps1 uses).
+
+
+
+ Step 2
+
Copy the installer
+
Make a folder per app under installers\ and copy the installer into it, for example installers\exampleapp\ExampleApp.msi.
+
+
+
+ Step 3
+
Add it to a list
+
Open the list in Notepad and add one entry per app to Applications. Each list contains an _example entry to copy; it is ignored. Apps install in list order, and an app already detected on the PC is skipped, so a PC can be re-imaged safely.
+
+
+
+
Field
Meaning
Required
+
Name
Shown in the log
Yes
+
Installer
Path under installers\. In JSON, write every \ as \\
Yes
+
Type
MSI (run with msiexec /i) or EXE (run directly)
Yes
+
InstallArgs
Silent-install switches, e.g. /qn /norestart for an MSI
Usually
+
DetectionMethod
Registry (a registry key, optionally one value) or File (a file or folder exists)
Recommended
+
DetectionPath
The registry key (HKLM:\\SOFTWARE\\...) or file path that proves the app is installed
With DetectionMethod
+
DetectionName DetectionValue
Registry only: a value under the key, and the data it must have
No
+
PCTypes
Limit the app to these PC types, e.g. ["gea-shopfloor-standard"]. Not set: every Shop Floor PC
No
+
LogFile
EXE only: the installer's own log, copied into the install log when it fails
+ Test before the floor:
+ A broken list or a failed install never stops imaging: it is logged and the PC carries on without that app. Image one test PC after every change and check its install log.
+
+
Reinstalling the server (sudo /opt/first-boot.sh) keeps both lists and the installers.
+
+
+
Script Options
Only needed for special cases. Run the script from an elevated PowerShell in C:\pxe-usb with options instead of -Interactive:
diff --git a/playbook/engineering/Install-EngineeringLayer.ps1 b/playbook/engineering/Install-EngineeringLayer.ps1
index 7a443bf..b7160b0 100644
--- a/playbook/engineering/Install-EngineeringLayer.ps1
+++ b/playbook/engineering/Install-EngineeringLayer.ps1
@@ -41,7 +41,10 @@ param(
# imaging - the unattend installs PowerShell 7 from C:\PreInstall\installers.
[string]$StagingRoot = 'C:\PreInstall\engineering',
[string]$LogDir = 'C:\Logs\Engineering',
- [string]$Installer = 'engineering_layer\Engineering_Install_v3.1.exe',
+ # Relative to StagingRoot. Empty: the newest
+ # engineering_layer\Engineering_Install_v.exe (uploaded on the
+ # PXE webapp, engineer image > Engineering Layer).
+ [string]$Installer = '',
[switch]$StageOnly
)
@@ -117,6 +120,18 @@ if (Test-Path -LiteralPath $marker) {
}
# --- stage the payload out of C:\Deploy -------------------------------------
+function Find-Installer([string]$Dir) {
+ Get-ChildItem -LiteralPath (Join-Path $Dir 'engineering_layer') -Filter 'Engineering_Install_v*.exe' -File -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -match '^Engineering_Install_v(\d+(\.\d+)*)\.exe$' } |
+ Sort-Object { $v = ($_.Name -replace '^Engineering_Install_v', '') -replace '\.exe$', ''; if ($v -notmatch '\.') { $v += '.0' }; [version]$v } -Descending |
+ Select-Object -First 1
+}
+if (-not $Installer) {
+ $found = Find-Installer $StagingRoot
+ if (-not $found) { $found = Find-Installer $Source }
+ $Installer = if ($found) { 'engineering_layer\' + $found.Name } else { 'engineering_layer\Engineering_Install_v3.1.exe' }
+}
+Write-EngLog "Installer: $Installer"
$stagedExe = Join-Path $StagingRoot $Installer
if (Test-Path -LiteralPath $stagedExe) {
diff --git a/webapp/app.py b/webapp/app.py
index f0b23bf..2a688aa 100644
--- a/webapp/app.py
+++ b/webapp/app.py
@@ -71,6 +71,7 @@ from views import dell_updates as dell_updates_views
from views import images as images_views
from views import images_drivers as images_drivers_views
from views import images_unattend as images_unattend_views
+from views import engineering_layer as engineering_layer_views
from views import ipxe as ipxe_views
app = Flask(__name__)
@@ -97,6 +98,7 @@ app.register_blueprint(bprt_sync_views.bp)
app.register_blueprint(images_views.bp)
app.register_blueprint(images_drivers_views.bp)
app.register_blueprint(images_unattend_views.bp) # Unattend review/save/undo
+app.register_blueprint(engineering_layer_views.bp) # Engineer image: Engineering Layer upload
# ---------------------------------------------------------------------------
diff --git a/webapp/services/engineering_layer.py b/webapp/services/engineering_layer.py
new file mode 100644
index 0000000..928420d
--- /dev/null
+++ b/webapp/services/engineering_layer.py
@@ -0,0 +1,287 @@
+"""The Engineering Layer payload of the engineer image.
+
+The gea-engineer unattend stages and runs
+Deploy/Applications/extra_engineering/Install-EngineeringLayer.ps1 (the
+playbook deploys it from the repo). It installs, from beside itself:
+
+ engineering_layer/Engineering_Install_v.exe (~530 MB)
+ prereqs/redistrib/..., prereqs/sxs/*.cab (VC++ / .NET 3.5)
+
+A new site has none of that; this module lets it be uploaded from the
+engineer image page (Engineering Layer tab):
+
+ * Engineering_Install_v.exe replaces the installer, keeps prereqs/
+ * a .zip with engineering_layer/ (and usually prereqs/) at its top level,
+ or under one extra_engineering/ folder replaces both
+
+The upload streams to disk, is checked (an MZ executable named
+Engineering_Install_v.exe; a zip with no absolute or .. paths), and
+only then swapped in: the current engineering_layer/ and prereqs/ move to
+UPDATES_DIR/englayer/archive// (the two newest are kept) and the
+new ones are renamed into place. Install-EngineeringLayer.ps1 is never
+touched (it belongs to the repo); it installs the newest
+Engineering_Install_v*.exe it finds.
+"""
+
+import fcntl
+import os
+import re
+import shutil
+import tempfile
+import zipfile
+from datetime import datetime
+
+import config
+from services import updates_common as uc
+
+IMAGE = "gea-engineer"
+EXE = re.compile(r"^Engineering_Install_v(\d+(?:\.\d+)*)\.exe$", re.IGNORECASE)
+PARTS = ("engineering_layer", "prereqs")
+KEEP_ARCHIVES = 2
+
+
+def root():
+ return os.path.join(config.SAMBA_SHARE, IMAGE, "Deploy", "Applications", "extra_engineering")
+
+
+def _state(*parts):
+ return uc.state_dir("englayer", *parts)
+
+
+def _version_key(v):
+ return tuple(int(x) for x in v.split("."))
+
+
+def _installers(folder):
+ out = []
+ try:
+ names = os.listdir(folder)
+ except OSError:
+ return out
+ for n in names:
+ m = EXE.match(n)
+ p = os.path.join(folder, n)
+ if m and os.path.isfile(p):
+ st = os.stat(p)
+ out.append({"name": n, "version": m.group(1), "size": st.st_size, "mtime": st.st_mtime})
+ return sorted(out, key=lambda x: _version_key(x["version"]), reverse=True)
+
+
+def _tree_size(path):
+ total, count = 0, 0
+ for dirpath, _, files in os.walk(path):
+ for f in files:
+ try:
+ total += os.path.getsize(os.path.join(dirpath, f))
+ count += 1
+ except OSError:
+ pass
+ return total, count
+
+
+def status():
+ r = root()
+ inst = _installers(os.path.join(r, "engineering_layer"))
+ pre = os.path.join(r, "prereqs")
+ pre_size, pre_files = _tree_size(pre) if os.path.isdir(pre) else (0, 0)
+ archives = []
+ adir = _state("archive")
+ for name in sorted(os.listdir(adir), reverse=True):
+ p = os.path.join(adir, name)
+ if os.path.isdir(p):
+ a_inst = _installers(os.path.join(p, "engineering_layer"))
+ archives.append({"name": name, "version": a_inst[0]["version"] if a_inst else None,
+ "prereqs": os.path.isdir(os.path.join(p, "prereqs"))})
+ return {
+ "root": r,
+ "image_exists": os.path.isdir(os.path.join(config.SAMBA_SHARE, IMAGE)),
+ "installers": inst,
+ "active": inst[0] if inst else None,
+ "prereqs": {"present": pre_files > 0, "files": pre_files, "size": pre_size,
+ "sxs": os.path.isdir(os.path.join(pre, "sxs")),
+ "redistrib": os.path.isdir(os.path.join(pre, "redistrib"))},
+ "script": os.path.isfile(os.path.join(r, "Install-EngineeringLayer.ps1")),
+ "archives": archives,
+ }
+
+
+class _Lock:
+ def __enter__(self):
+ self.fd = os.open(os.path.join(_state(), ".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 RuntimeError("another Engineering Layer upload is being applied") from None
+ return self
+
+ def __exit__(self, *exc):
+ fcntl.flock(self.fd, fcntl.LOCK_UN)
+ os.close(self.fd)
+ return False
+
+
+def _check_name(name):
+ name = os.path.basename(str(name or "")).strip()
+ if EXE.match(name) or name.lower().endswith(".zip"):
+ return name
+ raise ValueError("upload Engineering_Install_v.exe, or a .zip of the "
+ "extra_engineering payload (engineering_layer/ and prereqs/)")
+
+
+def _receive(stream, name, length):
+ """Stream to UPDATES_DIR/englayer/uploads/ (never held in memory)."""
+ folder = _state("uploads")
+ if length:
+ free = shutil.disk_usage(folder).free
+ if 2 * length > free - (1 << 30): # the upload plus its unpacked copy
+ raise RuntimeError(f"not enough disk space for {length // (1 << 20)} MB "
+ f"({free // (1 << 20)} MB free)")
+ fd, part = tempfile.mkstemp(dir=folder, prefix=".upload.", suffix=".part")
+ written = 0
+ try:
+ with os.fdopen(fd, "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")
+ return part, written
+ except BaseException:
+ if os.path.exists(part):
+ os.remove(part)
+ raise
+
+
+def _new_from_exe(part, name, work):
+ with open(part, "rb") as f:
+ if f.read(2) != b"MZ":
+ raise ValueError(f"{name} is not a Windows executable")
+ layer = os.path.join(work, "engineering_layer")
+ os.makedirs(layer)
+ shutil.move(part, os.path.join(layer, name))
+ return ("engineering_layer",)
+
+
+def _new_from_zip(part, work):
+ try:
+ zf = zipfile.ZipFile(part)
+ except zipfile.BadZipFile:
+ raise ValueError("the .zip file is damaged or not a zip") from None
+ with zf:
+ names = [i.filename.replace("\\", "/") for i in zf.infolist()]
+ for n in names:
+ if n.startswith("/") or ":" in n.split("/")[0] or ".." in n.split("/"):
+ raise ValueError(f"unsafe path in the zip: {n}")
+ # Accept the payload at the top, or inside one extra_engineering/ folder.
+ prefix = ""
+ if not any(n.split("/")[0] in PARTS for n in names):
+ tops = {n.split("/")[0] for n in names if n}
+ if len(tops) == 1:
+ prefix = tops.pop() + "/"
+ exes = [n for n in names if n.startswith(prefix + "engineering_layer/")
+ and EXE.match(n.rsplit("/", 1)[-1])]
+ if not exes:
+ raise ValueError("the zip has no engineering_layer/Engineering_Install_v.exe")
+ found = set()
+ for info in zf.infolist():
+ n = info.filename.replace("\\", "/")
+ if not n.startswith(prefix):
+ continue
+ rel = n[len(prefix):]
+ top = rel.split("/")[0]
+ if top not in PARTS:
+ continue # Install-EngineeringLayer.ps1 etc.: the repo owns those
+ found.add(top)
+ dest = os.path.join(work, *rel.split("/"))
+ if info.is_dir():
+ os.makedirs(dest, exist_ok=True)
+ continue
+ os.makedirs(os.path.dirname(dest), exist_ok=True)
+ with zf.open(info) as src, open(dest, "wb") as out:
+ shutil.copyfileobj(src, out, 1 << 20)
+ with open(os.path.join(work, exes[0][len(prefix):]), "rb") as f:
+ if f.read(2) != b"MZ":
+ raise ValueError(f"{exes[0]} is not a Windows executable")
+ return tuple(p for p in PARTS if p in found)
+
+
+def _fix_modes(path):
+ for dirpath, dirs, files in os.walk(path):
+ os.chmod(dirpath, 0o755)
+ for f in files:
+ os.chmod(os.path.join(dirpath, f), 0o644)
+
+
+def upload(stream, name, length=None):
+ """Receive one upload and swap it in. Returns a summary dict."""
+ name = _check_name(name)
+ if not os.path.isdir(os.path.join(config.SAMBA_SHARE, IMAGE)):
+ raise RuntimeError(f"no {IMAGE} image on this server yet; import it first")
+ part, size = _receive(stream, name, length)
+ work = tempfile.mkdtemp(dir=_state("uploads"), prefix=".new.")
+ try:
+ if EXE.match(name):
+ parts = _new_from_exe(part, name, work)
+ else:
+ parts = _new_from_zip(part, work)
+ _fix_modes(work)
+ r = root()
+ os.makedirs(r, exist_ok=True)
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
+ archive = os.path.join(_state("archive"), stamp)
+ with _Lock():
+ moved = []
+ for p in parts:
+ cur = os.path.join(r, p)
+ if os.path.exists(cur):
+ os.makedirs(archive, exist_ok=True)
+ shutil.move(cur, os.path.join(archive, p))
+ moved.append(p)
+ for p in parts:
+ shutil.move(os.path.join(work, p), os.path.join(r, p))
+ _prune()
+ inst = _installers(os.path.join(r, "engineering_layer"))
+ return {"name": name, "size": size, "replaced": list(parts),
+ "archived": stamp if moved else None,
+ "version": inst[0]["version"] if inst else None}
+ finally:
+ shutil.rmtree(work, ignore_errors=True)
+ if os.path.exists(part):
+ os.remove(part)
+
+
+def _prune():
+ adir = _state("archive")
+ old = sorted((d for d in os.listdir(adir) if os.path.isdir(os.path.join(adir, d))), reverse=True)
+ for d in old[KEEP_ARCHIVES:]:
+ shutil.rmtree(os.path.join(adir, d), ignore_errors=True)
+
+
+def restore(stamp):
+ """Put an archived payload back (the current one is archived in turn)."""
+ if not re.fullmatch(r"\d{8}-\d{6}", str(stamp or "")):
+ raise ValueError("no such archive")
+ src = os.path.join(_state("archive"), stamp)
+ if not os.path.isdir(src):
+ raise ValueError("no such archive")
+ r = root()
+ now = datetime.now().strftime("%Y%m%d-%H%M%S")
+ back = os.path.join(_state("archive"), now)
+ with _Lock():
+ parts = [p for p in PARTS if os.path.isdir(os.path.join(src, p))]
+ for p in parts:
+ cur = os.path.join(r, p)
+ if os.path.exists(cur):
+ os.makedirs(back, exist_ok=True)
+ shutil.move(cur, os.path.join(back, p))
+ for p in parts:
+ shutil.move(os.path.join(src, p), os.path.join(r, p))
+ shutil.rmtree(src, ignore_errors=True)
+ _prune()
+ return parts
diff --git a/webapp/services/image_pages.py b/webapp/services/image_pages.py
index 7f6a779..db3988a 100644
--- a/webapp/services/image_pages.py
+++ b/webapp/services/image_pages.py
@@ -31,6 +31,9 @@ IMAGE_TABS = (
{"id": "drivers", "label": "Drivers", "icon": "bi-hdd-network", "endpoint": "images_drivers.page"},
{"id": "unattend", "label": "Unattend", "icon": "bi-file-earmark-code", "endpoint": "unattend_editor"},
{"id": "os", "label": "OS and Packages", "icon": "bi-windows", "endpoint": "images.os_packages"},
+ # Only on the images listed in "images" (the header skips it elsewhere).
+ {"id": "englayer", "label": "Engineering Layer", "icon": "bi-box-seam",
+ "endpoint": "engineering_layer.page", "images": ("gea-engineer",)},
)
TAB_IDS = tuple(t["id"] for t in IMAGE_TABS)
diff --git a/webapp/templates/_image_header.html b/webapp/templates/_image_header.html
index 0d53804..e133b89 100644
--- a/webapp/templates/_image_header.html
+++ b/webapp/templates/_image_header.html
@@ -97,7 +97,7 @@