The lock records what IS in the wheelhouse, not what the application NEEDS, so an incomplete wheelhouse was locked, blessed and shipped - and only failed on an air-gapped server. That is not hypothetical. Assembling the wheelhouse anywhere other than Windows 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. The bundle built here was short exactly that one wheel. Both verifiers now cross-check wheels/ against the staged requirements.txt, ignoring markers, since a requirement guarded by sys_platform == 'win32' is precisely the one that must be present. Names are normalised to PEP 427 wheel form, so mysql-connector-python matches mysql_connector_python. bundle-lock.json is the first real lock: 42 files, cp314/win_amd64 - 39 wheels, Python 3.14.6, HttpPlatformHandler 1.2 and URL Rewrite. MySQL is absent and optional; a site choosing the bundled-database option adds it and re-locks. The naming gate now skips the installer's build output. It contains a staged copy of the application plus a second SPA build under dist-subpath, which --exclude-dir=dist does not match, so a staged bundle failed the gate on vendored minified JS nobody in this repository wrote.
170 lines
6.6 KiB
Python
170 lines
6.6 KiB
Python
#!/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 <bundle-root> <bundle-lock.json>
|
|
"""
|
|
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'),
|
|
('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> <bundle-lock.json>')
|
|
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())
|