Files
pxe-server/scripts/share-drift.py
cproudlock 68df59e117 Record who owns what, and report repo-vs-share drift
OWNERSHIP.md
Every expensive bug in this pipeline has had one shape: two systems setting the
same thing, last writer winning silently. Four happened on 2026-08-06 alone -
computer name (package vs run-enrollment), drive letters (PESetup vs a volume
finder), enrollment (package vs an at-logon -ManualFallback task that syspreped
finished machines), kiosk URLs (GE-Enforce vs site-config). Each is written down
with the evidence so the next person deletes a writer instead of adding one.

share-drift.py
The share is production and the repo is meant to describe it, but drift runs both
ways: live hand-edits nobody committed, and repo fixes never deployed. The
unattend outage lived only on the share while the repo copy was fine, and nothing
compared them.

Each mapped pair is classified. git-owned means the repo wins and the pair must
match - those fail the run. unreconciled means the two have genuinely diverged
and nobody has decided; reported, not failed. The unattends are unreconciled on
purpose: live is ~17 KB against ~12 KB in the repo, so a blind push would regress
production. Reads over SSH via base64 so BOM and CRLF survive the hop.

First run: 8 git-owned pairs all match, 4 known-unreconciled.

Run-ShopfloorSetup.ps1
Corrects a comment that was actively misleading. It claimed shopfloor PCs are
"vanilla by design" and that the orchestrator runs -ManualFallback to skip BPRT
injection and the package entirely. Shopfloor bays DO enrol - the SFLD package
joins Entra with its BPRT token and a human assigns the device category in
Intune. -ManualFallback runs sysprep /oobe /reboot, which is why wiring it to an
at-logon task destroyed the deployment chain.

The absent Entra wait is still correct, for a different reason: at that point the
bay is on the isolated PXE LAN with no route to Entra (579C144 held 172.16.9.81
and 172.24.19.142, neither in the production ranges). sync_intune retries until
the tech re-cables. "Entra ID Joined: false" right after imaging is normal.
2026-08-06 14:28:16 -04:00

154 lines
6.0 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/site-config.json",
"/srv/samba/enrollment/shopfloor-setup/site-config.json", 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),
# The config/ copy is what startnet stages to C:\Enrollment\site-config.json
# and it has its own edit history - it carried the dead tsgwp00524 host that
# the repo copy never had. Same filename, different lineage.
("playbook/shopfloor-setup/site-config.json",
"/srv/samba/enrollment/config/site-config.json", UNRECONCILED),
# The live unattends are the ones that boot machines and they are FAR ahead
# of the repo copies (17 KB vs 12 KB). Pushing the repo over them would
# regress production. Reconcile before promoting to git-owned.
("playbook/FlatUnattendW10-shopfloor.xml",
"/srv/samba/winpeapps/gea-shopfloor/Deploy/FlatUnattendW10.xml", UNRECONCILED),
("playbook/FlatUnattendW10.xml",
"/srv/samba/winpeapps/gea-standard/Deploy/FlatUnattendW10.xml", UNRECONCILED),
("playbook/FlatUnattendW10.xml",
"/srv/samba/winpeapps/gea-engineer/Deploy/FlatUnattendW10.xml", UNRECONCILED),
]
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())