CopyPackages copies the whole of <media>\Deploy\Applications to the target, recursively, with no manifest and no filtering, so every bay built from the shared gea-shopfloor media receives every shopfloor payload whatever its type. The obvious fix - repoint a path inside the shared media as each bay picks its type - races. Bays image concurrently and CopyPackages is fail-fast, so moving a path while another bay is mid-copy either hands it the wrong payload or fails its imaging outright, intermittently and unreproducibly. Give each type its own media directory instead, built once and never mutated. Everything in it is a symlink, so a type costs inodes rather than gigabytes and the shared media stays untouched while bays read it. Samba already serves this pattern: follow symlinks and wide links are on, and every image root reaches Sources, Operating Systems, Out-of-box Drivers and Packages the same way. Types come from the enrollment share's menu.json, the file the WinPE picker already renders, so the media set cannot drift from the boot menu. Deliberately NOT mirrored: the FlatUnattendW10.xml.pre-* backups and stray logs beside the real unattend - the view is the clean set PESetup actually reads. Each type is built into a scratch dir and swapped, so a bay mapping the path mid-refresh sees the old tree or the new one, never a half-built one. Applied on 172.16.9.1: 10 types, 190 links, all 60 critical paths resolve. An SMB client sees ordinary directories and files - Sources, Control, FlatUnattendW10.xml at 18078 bytes, HardwareDriver.json at 46113 - which is the behaviour that actually matters, since PESetup reads this over SMB from WinPE. Note for anyone extending this: passing the server-side script on stdin to "echo pxe | sudo -S bash -s" is a silent no-op. Bash inherits the password pipe, reads EOF, runs nothing and exits 0. It travels as a heredoc for that reason.
272 lines
11 KiB
Python
Executable File
272 lines
11 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""
|
|
build-pctype-media.py - Build a per-PCTYPE PESetup media view out of symlinks.
|
|
|
|
PESetup's CopyPackages copies the WHOLE of <media>\\Deploy\\Applications recursively
|
|
to the target (see docs/PESETUP-INTERNALS.md). It does not filter, so every shopfloor
|
|
bay built from the shared gea-shopfloor media receives every shopfloor payload
|
|
regardless of its PC type.
|
|
|
|
Rather than mutate the shared media per session - which races, because bays image
|
|
concurrently and CopyPackages is fail-fast, so pulling a path mid-copy fails the
|
|
OTHER bay's imaging - give each PC type its own media directory built entirely from
|
|
symlinks. Samba resolves them server-side (follow symlinks + wide links are already
|
|
on, and every image root already uses this pattern for Sources, Operating Systems,
|
|
Out-of-box Drivers and Packages), so WinPE sees ordinary directories.
|
|
|
|
Layout produced, per type:
|
|
|
|
_media/<pctype>/
|
|
Sources -> base Sources
|
|
Tools -> base Tools
|
|
Deploy/Control -> base Deploy/Control
|
|
Deploy/FlatUnattendW10.xml -> base Deploy/FlatUnattendW10.xml
|
|
Deploy/Operating Systems -> base Deploy/Operating Systems
|
|
Deploy/Out-of-box Drivers -> base Deploy/Out-of-box Drivers
|
|
Deploy/Packages -> base Deploy/Packages
|
|
Deploy/Tools -> base Deploy/Tools
|
|
Deploy/Applications/ real directory holding:
|
|
<every entry of base Deploy/Applications> -> base entry
|
|
<every entry of _shared/Applications/pctype/<pctype>> -> that entry
|
|
|
|
Everything is a link, so a type costs inodes rather than gigabytes, and the shared
|
|
media stays untouched and readable while bays image.
|
|
|
|
The type list comes from the enrollment share's menu.json, the same file the WinPE
|
|
picker renders, so the media set cannot drift from the boot menu.
|
|
|
|
Usage:
|
|
./build-pctype-media.py # show the plan, change nothing
|
|
./build-pctype-media.py --apply # build or refresh the media dirs
|
|
./build-pctype-media.py --verify # resolve every link, report breaks
|
|
./build-pctype-media.py --apply --prune # also remove types no longer listed
|
|
./build-pctype-media.py --base gea-standard --prefix _media-std --apply
|
|
|
|
Requires: sshpass (remote mode only)
|
|
"""
|
|
|
|
import argparse
|
|
import json
|
|
import posixpath
|
|
import subprocess
|
|
import sys
|
|
|
|
PXE_HOST = "172.16.9.1"
|
|
PXE_USER = "pxe"
|
|
PXE_PASS = "pxe"
|
|
IMAGE_BASE = "/srv/samba/winpeapps"
|
|
SHARED = IMAGE_BASE + "/_shared"
|
|
MENU_JSON = "/srv/samba/enrollment/shopfloor-setup/menu.json"
|
|
|
|
# Underscore prefix keeps these out of the webapp's image-type listing, the same way
|
|
# _shared is skipped. They are media views, not image types.
|
|
MEDIA_PREFIX = "_media"
|
|
DEFAULT_BASE = "gea-shopfloor"
|
|
|
|
# Per-type payload lives here. Absent dir just means "nothing type-specific yet".
|
|
PCTYPE_OVERLAY = SHARED + "/Applications/pctype"
|
|
|
|
# Deploy entries linked straight through from the base image. Anything else in the
|
|
# base Deploy (the FlatUnattendW10.xml.pre-* backups, stray logs) is deliberately
|
|
# NOT mirrored - the media view is the clean set PESetup actually reads.
|
|
DEPLOY_LINKS = [
|
|
"Control",
|
|
"FlatUnattendW10.xml",
|
|
"Operating Systems",
|
|
"Out-of-box Drivers",
|
|
"Packages",
|
|
"Tools",
|
|
]
|
|
ROOT_LINKS = ["Sources", "Tools"]
|
|
|
|
# PESetup will not get far without these, so they are what --verify resolves.
|
|
CRITICAL_PATHS = [
|
|
"Sources/PESetup.exe",
|
|
"Deploy/Control/HardwareDriver.json",
|
|
"Deploy/Control/OperatingSystem.json",
|
|
"Deploy/FlatUnattendW10.xml",
|
|
"Deploy/Operating Systems",
|
|
"Deploy/Out-of-box Drivers",
|
|
]
|
|
|
|
|
|
def ssh_cmd(host, cmd, stdin=None):
|
|
return subprocess.run(
|
|
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
|
|
"-o", "LogLevel=ERROR", f"{PXE_USER}@{host}", cmd],
|
|
capture_output=True, text=True, input=stdin)
|
|
|
|
|
|
def sh_quote(s):
|
|
return "'" + str(s).replace("'", "'\\''") + "'"
|
|
|
|
|
|
def run_root(host, script, apply_it):
|
|
"""Server-side work needs root: the share is root-owned.
|
|
|
|
The script travels inside the command as a heredoc, not on stdin. "echo pxe |
|
|
sudo -S bash -s" hands the password pipe to bash as well, so bash reads EOF
|
|
immediately, runs nothing, and still exits 0 - a silent no-op."""
|
|
if not apply_it:
|
|
return None
|
|
remote = "/tmp/build-pctype-media.$$.sh"
|
|
wrapped = (
|
|
"cat > %s <<'PCTYPEMEDIAEOF'\n%s\nPCTYPEMEDIAEOF\n"
|
|
"echo pxe | sudo -S -p '' bash %s; rc=$?; rm -f %s; exit $rc"
|
|
% (remote, script, remote, remote))
|
|
r = ssh_cmd(host, wrapped)
|
|
if r.returncode != 0:
|
|
sys.exit("ERROR: server-side step failed:\n%s\n%s" % (r.stdout, r.stderr))
|
|
return r.stdout
|
|
|
|
|
|
def read_types(host, menu_path):
|
|
r = ssh_cmd(host, "cat %s" % sh_quote(menu_path))
|
|
if r.returncode != 0:
|
|
sys.exit("ERROR: cannot read %s (%s)" % (menu_path, r.stderr.strip()))
|
|
try:
|
|
menu = json.loads(r.stdout)
|
|
except json.JSONDecodeError as e:
|
|
sys.exit("ERROR: %s does not parse: %s" % (menu_path, e))
|
|
types = [e["key"] for e in menu.get("shopfloor", [])
|
|
if e.get("enabled", True) and e.get("key")]
|
|
if not types:
|
|
sys.exit("ERROR: no enabled types in %s" % menu_path)
|
|
return types
|
|
|
|
|
|
def list_dir(host, path):
|
|
r = ssh_cmd(host, "ls -1 %s 2>/dev/null" % sh_quote(path))
|
|
return [n for n in r.stdout.splitlines() if n] if r.returncode == 0 else []
|
|
|
|
|
|
def plan_for_type(base_dir, media_dir, pctype, base_apps, overlay_entries):
|
|
"""Every link this type needs, as (link_path, target) pairs."""
|
|
links = []
|
|
for name in ROOT_LINKS:
|
|
links.append((posixpath.join(media_dir, name),
|
|
posixpath.join(base_dir, name)))
|
|
for name in DEPLOY_LINKS:
|
|
links.append((posixpath.join(media_dir, "Deploy", name),
|
|
posixpath.join(base_dir, "Deploy", name)))
|
|
apps_dir = posixpath.join(media_dir, "Deploy", "Applications")
|
|
for name in base_apps:
|
|
links.append((posixpath.join(apps_dir, name),
|
|
posixpath.join(base_dir, "Deploy", "Applications", name)))
|
|
# Overlay wins: a type-specific entry replaces the base entry of the same name.
|
|
overlay_dir = posixpath.join(PCTYPE_OVERLAY, pctype)
|
|
for name in overlay_entries:
|
|
links.append((posixpath.join(apps_dir, name),
|
|
posixpath.join(overlay_dir, name)))
|
|
return links
|
|
|
|
|
|
def build_script(media_dir, links):
|
|
"""Rebuild the type's dir from scratch. Idempotent, and drops stale links.
|
|
|
|
Builds into a scratch dir and swaps, so a bay that maps this path mid-refresh
|
|
sees either the old tree or the new one, never a half-built one."""
|
|
tmp = media_dir + ".new"
|
|
out = ["set -e", "rm -rf %s" % sh_quote(tmp)]
|
|
dirs = {posixpath.dirname(link) for link, _ in links}
|
|
for d in sorted(dirs):
|
|
out.append("mkdir -p %s" % sh_quote(d.replace(media_dir, tmp, 1)))
|
|
for link, target in links:
|
|
out.append("ln -s %s %s" % (sh_quote(target),
|
|
sh_quote(link.replace(media_dir, tmp, 1))))
|
|
out.append("rm -rf %s" % sh_quote(media_dir + ".old"))
|
|
out.append("if [ -e %s ]; then mv %s %s; fi"
|
|
% (sh_quote(media_dir), sh_quote(media_dir), sh_quote(media_dir + ".old")))
|
|
out.append("mv %s %s" % (sh_quote(tmp), sh_quote(media_dir)))
|
|
out.append("rm -rf %s" % sh_quote(media_dir + ".old"))
|
|
return "\n".join(out) + "\n"
|
|
|
|
|
|
def verify(host, media_root, types):
|
|
"""Resolve the paths PESetup reads. A dangling link here is a dead media view."""
|
|
checks = []
|
|
for pctype in types:
|
|
for rel in CRITICAL_PATHS:
|
|
checks.append(posixpath.join(media_root, pctype, rel))
|
|
script = "\n".join("if [ -e %s ]; then echo \"OK %s\"; else echo \"DEAD %s\"; fi"
|
|
% (sh_quote(p), p, p) for p in checks)
|
|
r = ssh_cmd(host, "bash -s", stdin=script)
|
|
dead = [line for line in r.stdout.splitlines() if line.startswith("DEAD")]
|
|
for line in r.stdout.splitlines():
|
|
if line.startswith("DEAD"):
|
|
print(" " + line)
|
|
print(" %d paths checked, %d dead" % (len(checks), len(dead)))
|
|
return len(dead)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(
|
|
description="Build per-PCTYPE PESetup media views out of symlinks.")
|
|
parser.add_argument("--server", default=PXE_HOST, help="PXE server (default: %s)" % PXE_HOST)
|
|
parser.add_argument("--base", default=DEFAULT_BASE,
|
|
help="image type the view is built from (default: %s)" % DEFAULT_BASE)
|
|
parser.add_argument("--prefix", default=MEDIA_PREFIX,
|
|
help="directory under winpeapps to hold the views (default: %s)"
|
|
% MEDIA_PREFIX)
|
|
parser.add_argument("--menu", default=MENU_JSON, help="menu.json path on the server")
|
|
parser.add_argument("--types", nargs="+", help="explicit type list, bypassing menu.json")
|
|
parser.add_argument("--apply", action="store_true", help="write; default is a dry run")
|
|
parser.add_argument("--prune", action="store_true",
|
|
help="remove media views whose type is no longer listed")
|
|
parser.add_argument("--verify", action="store_true",
|
|
help="resolve the critical paths in each view and exit")
|
|
args = parser.parse_args()
|
|
|
|
base_dir = posixpath.join(IMAGE_BASE, args.base)
|
|
media_root = posixpath.join(IMAGE_BASE, args.prefix)
|
|
types = args.types or read_types(args.server, args.menu)
|
|
|
|
if args.verify:
|
|
print("Verifying %d media views under %s" % (len(types), media_root))
|
|
return 1 if verify(args.server, media_root, types) else 0
|
|
|
|
base_apps = list_dir(args.server, posixpath.join(base_dir, "Deploy", "Applications"))
|
|
if not base_apps:
|
|
sys.exit("ERROR: %s/Deploy/Applications is empty or unreadable" % base_dir)
|
|
|
|
print("base image : %s" % base_dir)
|
|
print("media root : %s" % media_root)
|
|
print("types : %d from %s" % (len(types), "--types" if args.types else args.menu))
|
|
print("base apps : %s" % ", ".join(base_apps))
|
|
print()
|
|
|
|
total_links = 0
|
|
for pctype in types:
|
|
media_dir = posixpath.join(media_root, pctype)
|
|
overlay_entries = list_dir(args.server, posixpath.join(PCTYPE_OVERLAY, pctype))
|
|
links = plan_for_type(base_dir, media_dir, pctype, base_apps, overlay_entries)
|
|
total_links += len(links)
|
|
print("%-32s %2d links%s" % (
|
|
pctype, len(links),
|
|
" overlay: " + ", ".join(overlay_entries) if overlay_entries else ""))
|
|
run_root(args.server, build_script(media_dir, links), args.apply)
|
|
|
|
print()
|
|
if args.prune:
|
|
existing = list_dir(args.server, media_root)
|
|
stale = [n for n in existing if n not in types and not n.endswith((".new", ".old"))]
|
|
for name in stale:
|
|
print("prune %s" % posixpath.join(media_root, name))
|
|
run_root(args.server, "rm -rf %s\n" % sh_quote(posixpath.join(media_root, name)),
|
|
args.apply)
|
|
if not stale:
|
|
print("prune: nothing stale")
|
|
print()
|
|
|
|
if not args.apply:
|
|
print("DRY RUN: %d links across %d types. Re-run with --apply to build."
|
|
% (total_links, len(types)))
|
|
return 0
|
|
|
|
print("Built %d links across %d types. Verifying." % (total_links, len(types)))
|
|
return 1 if verify(args.server, media_root, types) else 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|