#!/usr/bin/env python3 """Check a staged installer bundle against bundle-lock.json. Prints one line per problem and exits non-zero if there are any. Exits 0 only when the bundle's third-party payload is EXACTLY what the lock describes: no missing file, no unexpected extra file, no changed content. Why this exists alongside bundle-lock.ps1, which does the same job: - bundle-lock.ps1 is canonical. It runs at INSTALL time on the target server, where PowerShell is the only thing guaranteed to be present - Python is not installed until stage 2, and verifying the payload after running part of it would defeat the purpose. - This file lets the Linux builder (build-installer.sh) do the same check without adding pwsh as a build dependency. The two are kept honest by tests/test_bundle_lock.py, which runs BOTH against the same fixtures and fails if they disagree. Usage: verify_bundle_lock.py """ import hashlib import json import os import re import sys # Must match $script:BundlePayloads in bundle-lock.ps1. PAYLOADS = [ ('wheels', True, 'Python wheels for the offline install'), ('python', True, 'the Python installer'), ('httpplatformhandler', True, 'the IIS module that launches waitress'), ('urlrewrite', False, 'IIS URL Rewrite, for the client-IP rule'), ('mysqlclient', False, 'mysql/mysqldump, for backups against a remote database'), ('vcredist', False, 'the Visual C++ runtime MySQL requires'), ('mysql', False, 'MySQL, for the bundled-database option'), ] def digest(path): sha = hashlib.sha256() with open(path, 'rb') as fh: for chunk in iter(lambda: fh.read(1024 * 1024), b''): sha.update(chunk) return sha.hexdigest() def payload_files(directory): """Every file under the directory, keyed by forward-slashed relative path.""" found = {} if not os.path.isdir(directory): return found for root, _dirs, files in os.walk(directory): for name in files: full = os.path.join(root, name) rel = os.path.relpath(full, directory).replace(os.sep, '/') found[rel] = {'sha256': digest(full), 'size': os.path.getsize(full)} return found def normalize(name): """PEP 427 wheel filename form: runs of non-alphanumerics become one _.""" return re.sub(r'[^A-Za-z0-9.]+', '_', name).lower() def requirement_pins(requirements_path): """Every 'name==version' pinned in a lockfile, including marked-out ones. Markers are deliberately IGNORED. A requirement guarded by sys_platform == 'win32' is exactly the case that must be present, because the target is Windows and the wheelhouse is usually assembled somewhere else. """ pins = {} with open(requirements_path) as fh: for line in fh: line = line.strip() if not line or line.startswith('#'): continue match = re.match(r'^([A-Za-z0-9._-]+)==([^\s;\\]+)', line) if match: pins[normalize(match.group(1))] = match.group(2) return pins def check_wheelhouse_covers_requirements(bundle_root): """The lock records what IS in the wheelhouse, not what the app NEEDS. Without this, an incomplete wheelhouse gets locked and blessed, and the install fails on an air-gapped server. That is not hypothetical: assembling the wheelhouse on Linux silently omits colorama, a win32-only dependency of click, because pip evaluates environment markers against the machine doing the downloading rather than the machine being targeted. """ wheels = os.path.join(bundle_root, 'wheels') requirements = os.path.join(bundle_root, 'app', 'requirements.txt') if not os.path.isdir(wheels) or not os.path.exists(requirements): return [] have = os.listdir(wheels) problems = [] for name, version in sorted(requirement_pins(requirements).items()): prefix = '%s-%s-' % (name, version) if not any(f.lower().startswith(prefix) for f in have): problems.append( 'wheels/ has no wheel for %s==%s, which requirements.txt pins ' '(a marked-out dependency still installs on Windows)' % (name, version)) return problems def verify(bundle_root, lock): problems = [] locked = lock.get('payloads') if not locked: return ['bundle-lock.json has no "payloads" section'] for name, required, what in PAYLOADS: directory = os.path.join(bundle_root, name) present = os.path.isdir(directory) if name not in locked: if present: problems.append( '%s/ is present but is not in bundle-lock.json - regenerate the lock' % name) elif required: problems.append( '%s/ is required but is in neither the bundle nor the lock' % name) continue if not present: if required or locked[name].get('required'): problems.append('%s/ is in the lock but missing from the bundle (%s)' % (name, what)) continue expected = locked[name].get('files', {}) actual = payload_files(directory) for rel, want in sorted(expected.items()): got = actual.get(rel) if got is None: problems.append('%s/%s is in the lock but missing from the bundle' % (name, rel)) elif got['sha256'] != want['sha256']: problems.append( '%s/%s does NOT match the lock (expected sha256 %s..., got %s...)' % (name, rel, want['sha256'][:12], got['sha256'][:12])) elif int(got['size']) != int(want['size']): # Impossible for a matching sha256, so the lock was hand-edited. problems.append( '%s/%s size disagrees with the lock - the lock has been edited by hand' % (name, rel)) for rel in sorted(actual): if rel not in expected: problems.append( '%s/%s is in the bundle but NOT in the lock (unexpected extra file)' % (name, rel)) problems.extend(check_wheelhouse_covers_requirements(bundle_root)) return problems def main(): if len(sys.argv) != 3: sys.exit('usage: verify_bundle_lock.py ') bundle_root, lock_path = sys.argv[1], sys.argv[2] if not os.path.exists(lock_path): print('no bundle-lock.json at %s' % lock_path) return 1 with open(lock_path) as fh: lock = json.load(fh) problems = verify(bundle_root, lock) for problem in problems: print(problem) return 1 if problems else 0 if __name__ == '__main__': sys.exit(main())