100 lines
4.2 KiB
Python
100 lines
4.2 KiB
Python
#!/usr/bin/env python3
|
|
"""Materialize a pinned engineering release without relying on generated files."""
|
|
import argparse
|
|
import hashlib
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import tempfile
|
|
|
|
|
|
def git(directory, *arguments):
|
|
result = subprocess.run(['git', '-C', str(directory), *arguments],
|
|
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
env={**os.environ, 'GIT_TERMINAL_PROMPT': '0'}, timeout=180)
|
|
if result.returncode:
|
|
raise ValueError('Git operation failed; check repository access and credentials')
|
|
return result.stdout.strip()
|
|
|
|
|
|
def files(directory):
|
|
result = {}
|
|
if not directory.exists():
|
|
return result
|
|
for path in directory.rglob('*'):
|
|
relative = path.relative_to(directory)
|
|
if any(part in {'target', '__pycache__'} for part in relative.parts):
|
|
continue
|
|
if path.is_symlink():
|
|
raise ValueError('Symlinks are not allowed in generated resources: ' + str(relative))
|
|
if path.is_file():
|
|
result[str(relative)] = (hashlib.sha256(path.read_bytes()).hexdigest(),
|
|
bool(path.stat().st_mode & 0o111))
|
|
return result
|
|
|
|
|
|
def materialize(root, check=False):
|
|
root = Path(root).resolve()
|
|
lock = json.loads((root / 'engineering.lock.json').read_text())
|
|
if not re.fullmatch(r'v\d+\.\d+\.\d+', lock['tag']):
|
|
raise ValueError('Engineering tag must be vMAJOR.MINOR.PATCH')
|
|
if not re.fullmatch(r'[0-9a-f]{40}', lock['commit']):
|
|
raise ValueError('Engineering commit must be a full SHA')
|
|
with tempfile.TemporaryDirectory(prefix='engineering-fetch-') as temporary:
|
|
checkout = Path(temporary)
|
|
git(checkout, 'init', '-q')
|
|
git(checkout, 'fetch', '--quiet', '--depth=1', '--no-tags', '--',
|
|
lock['repository'], 'refs/tags/' + lock['tag'])
|
|
actual = git(checkout, 'rev-parse', 'FETCH_HEAD^{commit}')
|
|
if actual != lock['commit']:
|
|
raise ValueError('Engineering tag moved: resolved commit differs from lock')
|
|
git(checkout, 'checkout', '--quiet', '--detach', actual)
|
|
source = checkout / 'resources'
|
|
if not source.is_dir():
|
|
raise ValueError('Engineering release has no resources directory')
|
|
files(source) # Validate before copying, including symlinks.
|
|
(source / 'SOURCE.json').write_text(json.dumps(lock, indent=2, sort_keys=True) + '\n')
|
|
destination = root / 'generated'
|
|
if destination.is_symlink():
|
|
raise ValueError('generated/ must not be a symlink')
|
|
expected, existing = files(source), files(destination)
|
|
if check:
|
|
changed = sorted(k for k in expected.keys() | existing.keys()
|
|
if expected.get(k) != existing.get(k))
|
|
if changed:
|
|
raise ValueError('Generated resources differ; regenerate and commit:\n' + '\n'.join(changed))
|
|
print('Engineering resources match ' + lock['tag'] + ' at ' + actual)
|
|
return
|
|
# Stage on the same filesystem. Preserve the previous tree until replacement succeeds.
|
|
with tempfile.TemporaryDirectory(prefix='.engineering-', dir=root) as staging:
|
|
staged = Path(staging) / 'next'
|
|
previous = Path(staging) / 'previous'
|
|
shutil.copytree(source, staged)
|
|
if destination.exists():
|
|
destination.rename(previous)
|
|
try:
|
|
staged.rename(destination)
|
|
except BaseException:
|
|
if previous.exists():
|
|
previous.rename(destination)
|
|
raise
|
|
print('Generated engineering resources from ' + lock['tag'] + ' at ' + actual)
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--check', action='store_true')
|
|
parser.add_argument('--root', type=Path, default=Path(__file__).resolve().parents[1])
|
|
args = parser.parse_args()
|
|
try:
|
|
materialize(args.root, args.check)
|
|
except (ValueError, KeyError, OSError, subprocess.TimeoutExpired) as error:
|
|
parser.exit(1, str(error) + '\n')
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|