Files
pxe-server/scripts/share-drift.py
cproudlock 04db6d6605 Generic build for other GE sites: no WJ data, one shopfloor type, Windows USB builder
Everything West Jefferson specific moved out to a separate repo
(pxe-site-wj): NTLARS/UDC/heat-treat backups keyed by WJ machine numbers,
CMM and Wax/Trace bay lists, WJ OpenText profiles, site-config.json, the
WJ preinstall list, and the WJ shopfloor PC types (Collections,
NoCollections, Common, Keyence, CMM, Genspect, HeatTreat, Wax/Trace,
Display, PartMarker) with GE-Enforce, machine numbers and the desktop
layout. The part-marker bench tools moved to their own repo. Dead
scripts removed: build-proxmox-iso, deploy-bios, sync_hardware_models,
mirror-from-gold, pull-bios, startnet-template.cmd.

Shopfloor is one generic type, gea-shopfloor-standard, with today's
timing:
- PPKG at first logon (unattend Order 10, unchanged).
- Install-AppList.ps1 runs C:\PreInstall\preinstall.json during setup.
- A new SYSTEM task, GE Shopfloor Post-PPKG, runs postppkg.json once
  after the post-PPKG reboot. It also clears AutoLogonCount after the
  ShopFloor handover (the Users-level monitor can be denied that HKLM
  write) and removes the Intune Sync task when done.
- Both lists ship empty. A site adds entries and installers.
- WinPE choice 3 goes straight to the PPKG menu. Staging, fetch and heal
  carry only the generic payload. The webapp's shopfloor sub-menu editor
  is gone, and the imaging tiles name the new stages.
- The playbook deploys the empty lists only if missing. It now deploys
  the PowerShell 7 MSI the shopfloor unattend installs, which it never
  did before.

Blancco site settings move into the webapp (Updates > Blancco releases):
business location, licensing Wi-Fi SSID and password. They are rendered
into the served preferences.xml and applied to every staged release, and
are seeded from site-vars (site_name, blancco_wifi_ssid,
blancco_wifi_password) only while none exist. The repo XML carries no
site values; the plaintext Wi-Fi password is gone from it.

New site_name and site_timezone settings: report and evidence-pack site
name, server clock and webapp time zone. No more West Jefferson defaults
in webapp/config.py or the dashboard title. Sync-BPRT.ps1 gets the
server address filled in at download instead of WJ's GE address.

USB on Windows:
- scripts/Build-PxeUsb.ps1 runs the same checks as build-usb.sh. It
  writes one FAT32 CIDATA partition (ISO files plus patched grub.cfg plus
  payload) with diskpart and reads every file back by SHA-256.
- Tested with Windows PowerShell 5.1 in the win11 VM, and the layout
  installs and first-boots in the pxetest VM.
- scripts/stage-usb-share.sh stages the build folder on a share.
- site-content/ is optional for both builders.

KB docs/kb/How_To_Deploy_PXE_Server_New_Site.html: new-site deployment,
Windows only, with a site-vars.yml guide.

Checks: webapp tests pass (5 suites); the unattend lint and playbook
syntax checks pass; every .ps1 parses; there are no WJ hosts, IPs, names
or PC types in playbook/, webapp/, scripts/ or autoinstall/.
2026-10-16 20:37:22 -04:00

156 lines
6.2 KiB
Python
Executable File

#!/usr/bin/env python3
"""
share-drift.py - report where the repo and the live share disagree.
WHY
The share is production. The repo is meant to describe it. On 2026-08-06 an
unattend edit made directly on the share broke every shopfloor, standard and
engineer build for a day - while the repo copy was fine the whole time. Nobody
could have known, because nothing compared them.
Worse, drift runs both directions. Some live files are AHEAD of the repo
(hand-edits nobody committed) and some repo files are ahead of live (fixes never
deployed). Blindly pushing the repo over the share would have overwritten a
working 17 KB unattend with a stale 12 KB one.
So this tool REPORTS by default and never writes. Each pair is classified:
git-owned the repo is the source of truth. Safe to push.
unreconciled the two have diverged and nobody has decided which wins. NOT safe
to push. Reconcile by hand, then move the pair to git-owned.
Usage:
./share-drift.py # status table
./share-drift.py --diff # show the actual differences
./share-drift.py --only unattend # filter by substring
Exit non-zero if any git-owned pair differs, so it can gate a deploy. An
unreconciled pair that differs is reported but does not fail - that is a known
state, not a regression.
"""
import argparse
import base64
import difflib
import hashlib
import os
import subprocess
import sys
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
PXE_HOST = "172.16.9.1"
PXE_USER = "pxe"
PXE_PASS = "pxe"
GIT_OWNED, UNRECONCILED = "git-owned", "unreconciled"
# (repo path, live path, ownership)
PAIRS = [
("playbook/shopfloor-setup/run-enrollment.ps1",
"/srv/samba/enrollment/scripts/run-enrollment.ps1", GIT_OWNED),
("playbook/scripts/preflight.ps1",
"/srv/samba/enrollment/scripts/preflight.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Run-ShopfloorSetup.ps1",
"/srv/samba/enrollment/shopfloor-setup/Run-ShopfloorSetup.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Verify-And-Heal-Staging.ps1",
"/srv/samba/enrollment/shopfloor-setup/Verify-And-Heal-Staging.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Fetch-StagingPayload.ps1",
"/srv/samba/enrollment/shopfloor-setup/Fetch-StagingPayload.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Shopfloor/Install-AppList.ps1",
"/srv/samba/enrollment/shopfloor-setup/Shopfloor/Install-AppList.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Shopfloor/Register-PostPpkgTask.ps1",
"/srv/samba/enrollment/shopfloor-setup/Shopfloor/Register-PostPpkgTask.ps1", GIT_OWNED),
("playbook/shopfloor-setup/Shopfloor/Invoke-PostPpkg.ps1",
"/srv/samba/enrollment/shopfloor-setup/Shopfloor/Invoke-PostPpkg.ps1", GIT_OWNED),
("playbook/shopfloor-setup/BIOS/models.txt",
"/srv/samba/winpeapps/_shared/BIOS/models.txt", GIT_OWNED),
("playbook/shopfloor-setup/BIOS/check-bios.cmd",
"/srv/samba/winpeapps/_shared/BIOS/check-bios.cmd", GIT_OWNED),
# All three unattends are reconciled. The live files were ahead of the repo
# and the LIVE side won, because these are what boot machines - pushing the
# repo over them is precisely the 2026-08-06 outage.
#
# Engineer has its own repo file. It used to be compared against the
# standard unattend, which is a different answer file, so the pair reported
# DIFFERS permanently - and a light that is always red is one nobody reads.
("playbook/FlatUnattendW10-shopfloor.xml",
"/srv/samba/winpeapps/gea-shopfloor/Deploy/FlatUnattendW10.xml", GIT_OWNED),
("playbook/FlatUnattendW10.xml",
"/srv/samba/winpeapps/gea-standard/Deploy/FlatUnattendW10.xml", GIT_OWNED),
("playbook/FlatUnattendW10-engineer.xml",
"/srv/samba/winpeapps/gea-engineer/Deploy/FlatUnattendW10.xml", GIT_OWNED),
]
def ssh(cmd):
return subprocess.run(
["sshpass", "-p", PXE_PASS, "ssh", "-o", "StrictHostKeyChecking=no",
"-o", "LogLevel=ERROR", f"{PXE_USER}@{PXE_HOST}", cmd],
capture_output=True, text=True)
def live_bytes(path):
"""base64 in transit so BOM and CRLF survive the hop unchanged."""
r = ssh("base64 -w0 '%s' 2>/dev/null" % path)
if r.returncode != 0 or not r.stdout.strip():
return None
return base64.b64decode(r.stdout.strip())
def repo_bytes(path):
try:
with open(os.path.join(REPO, path), "rb") as f:
return f.read()
except OSError:
return None
def short(b):
return hashlib.md5(b).hexdigest()[:10] if b is not None else "MISSING"
def main():
ap = argparse.ArgumentParser(description="Report repo vs live share drift.")
ap.add_argument("--diff", action="store_true", help="show the actual differences")
ap.add_argument("--only", help="only pairs whose paths contain this substring")
args = ap.parse_args()
blocking = 0
known = 0
print("%-13s %-44s %-11s %-11s" % ("OWNERSHIP", "LIVE PATH", "REPO", "LIVE"))
print("-" * 84)
for repo_path, live_path, owner in PAIRS:
if args.only and args.only not in repo_path and args.only not in live_path:
continue
rb, lb = repo_bytes(repo_path), live_bytes(live_path)
same = rb is not None and lb is not None and rb == lb
tag = "same" if same else "DIFFERS"
if not same:
if owner == GIT_OWNED:
blocking += 1
else:
known += 1
print("%-13s %-44s %-11s %-11s %s"
% (owner, live_path.replace("/srv/samba/", ""), short(rb), short(lb), tag))
if args.diff and not same and rb is not None and lb is not None:
a = rb.decode("utf-8", "replace").replace("\r\n", "\n").splitlines()
b = lb.decode("utf-8", "replace").replace("\r\n", "\n").splitlines()
for line in list(difflib.unified_diff(a, b, "repo", "live", lineterm="", n=1))[:40]:
print(" " + line)
print()
print("-" * 84)
print("%d git-owned pair(s) out of sync, %d known-unreconciled" % (blocking, known))
if blocking:
print("FAILED: a git-owned file differs from the share. Deploy it or commit the live version.")
return 1
print("PASSED: everything git-owned matches the share.")
return 0
if __name__ == "__main__":
sys.exit(main())