#!/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 \\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// 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: -> base entry > -> 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())