diff --git a/scripts/build-pctype-media.py b/scripts/build-pctype-media.py new file mode 100755 index 0000000..994b698 --- /dev/null +++ b/scripts/build-pctype-media.py @@ -0,0 +1,271 @@ +#!/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())