Files
pxe-server/scripts/share-drift.py
cproudlock fa79f76951 Share accounts: per-site Samba passwords from the webapp
Tools > Share accounts sets the pxe-upload, blancco and clonezilla Samba
passwords. Saving one runs smbpasswd and writes it everywhere it is used:
startnet.cmd in boot.wim (pxe-upload), the served Blancco preferences and
the live config.img (blancco), the iPXE menu's Clonezilla entries
(clonezilla). Refused while a PC is imaging unless forced. Stored 0600 in
UPDATES_DIR/shares/accounts.json, never shown back.

- The stored values are the authority: the playbook only creates the
  Samba accounts, seeds the store from site-vars (pxe_upload_password,
  blancco_smb_password, clonezilla_smb_password) for accounts it does not
  hold, and runs "share_accounts apply" at the end of every run.
- Every startnet save re-pins SHAREPASS, so a restored snapshot cannot
  bring an old password back; new menu blocks use %SHAREUSER%/%SHAREPASS%.
- Shop floor unattend Order 4 reads the login from fetch-source.txt;
  Invoke-PostPpkg deletes that file once the PPKG is applied.
- Upload-Image.ps1 asks for the password unless -Pass is given.
- pxe_admin_password: the Linux pxe login. Required while the box still
  has the shipped "pxe"; set with chpasswd when it differs.
- Blancco site settings now also reach the live config.img (BIOS path).
2026-10-17 12:29:06 -04:00

156 lines
6.3 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 = os.environ.get("PXE_PASS") or "pxe" # the site's pxe_admin_password
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())