Second failure from the Server 2019 test. The previous fix worked - msiexec went from exit 1639 (ERROR_INVALID_COMMAND_LINE, which is why it printed its usage dialog) to exit 1603 (ERROR_INSTALL_FAILURE), so the command line parses now and the MSI itself is failing. It failed in 1.1 seconds. An MSI that dies that fast has not begun installing; it has failed a launch condition. MySQL 8.4 requires the Visual C++ redistributable and a bare Windows Server does not ship it - the same runtime mysql.exe and mysqldump.exe import, which was visible when their DLL dependencies were trimmed and went unnoticed. Stage 0 now installs VC_redist.x64.exe from the bundle before touching MySQL, skipping it when vcruntime140.dll is already present, and fails with a sentence naming the requirement if the redistributable is absent from the bundle altogether. vcredist\ is an optional locked payload. msiexec also gets /l*v now. A bare 1603 names neither the failing action nor the reason, and it is the most common MySQL install failure - diagnosing this one took a launch-condition inference rather than a log. The MSI log lands beside the installer's own in ProgramData, so the next failure is readable instead of guessed at.
172 lines
6.7 KiB
Python
172 lines
6.7 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'),
|
|
('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> <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())
|