94 lines
5.1 KiB
Python
94 lines
5.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Build reproducible, passive Ed25519-signed runtime release artifacts."""
|
|
import argparse
|
|
import json
|
|
import os
|
|
from pathlib import Path
|
|
import runpy
|
|
import shutil
|
|
import stat
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import zipfile
|
|
|
|
ENGINE = runpy.run_path(str(Path(__file__).resolve().parents[1] / 'hydra/update_engine.py'))
|
|
|
|
|
|
def build(source, output, private_key, version, sequence):
|
|
source, output, private_key = Path(source).resolve(), Path(output).absolute(), Path(private_key).absolute()
|
|
if private_key.is_symlink() or not private_key.is_file():
|
|
raise ValueError('Private key must be an existing regular file outside Git')
|
|
info = private_key.stat()
|
|
if info.st_uid != os.geteuid() or info.st_mode & 0o077:
|
|
raise ValueError('Private key must be user-owned and private (0600)')
|
|
git = subprocess.run(['git', '-C', str(private_key.parent), 'rev-parse', '--show-toplevel'], capture_output=True, text=True)
|
|
if private_key.is_relative_to(source) or git.returncode == 0:
|
|
raise ValueError('Private key must remain outside Git and the source/build context')
|
|
commit = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], check=True, capture_output=True, text=True).stdout.strip()
|
|
files = {}
|
|
for path in sorted((source / 'hydra').glob('*.py')):
|
|
name = 'usr/lib/hydra/hydra/' + path.name
|
|
if ENGINE['allowed'](name):
|
|
if path.is_symlink():
|
|
raise ValueError('Source payload symlink refused')
|
|
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
|
files['usr/lib/hydra/bin/hydra'] = (ENGINE['read_bytes'](source / 'bin/hydra', ENGINE['FILE_LIMIT']), 0o755)
|
|
for name in sorted(ENGINE['EXACT'] - {'usr/lib/hydra/bin/hydra'}):
|
|
path = source / 'image/rootfs' / name
|
|
if path.exists():
|
|
files[name] = (ENGINE['read_bytes'](path, ENGINE['FILE_LIMIT']), 0o644)
|
|
# Construct privately, verify our complete artifacts, then publish a new directory.
|
|
with tempfile.TemporaryDirectory(prefix='hydra-release-build-') as temporary:
|
|
temporary = Path(temporary)
|
|
with zipfile.ZipFile(temporary / 'runtime.zip', 'w', compression=zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
|
|
for name, (data, mode) in sorted(files.items()):
|
|
info = zipfile.ZipInfo(name, (1980, 1, 1, 0, 0, 0))
|
|
info.create_system = 3
|
|
info.external_attr = (stat.S_IFREG | mode) << 16
|
|
info.compress_type = zipfile.ZIP_DEFLATED
|
|
archive.writestr(info, data, compresslevel=9)
|
|
data = ENGINE['read_bytes'](temporary / 'runtime.zip', ENGINE['LIMIT'])
|
|
manifest = {'format': 1, 'product': 'Hydra OS', 'scope': 'runtime', 'channel': 'stable',
|
|
'architecture': 'x86_64', 'version': version, 'sequence': sequence, 'source_commit': commit,
|
|
'archive': {'name': 'runtime.zip', 'size': len(data), 'sha256': ENGINE['digest'](data)},
|
|
'files': {name: {'size': len(content), 'mode': mode, 'sha256': ENGINE['digest'](content)}
|
|
for name, (content, mode) in sorted(files.items())}}
|
|
raw = (json.dumps(manifest, sort_keys=True, separators=(',', ':')) + '\n').encode()
|
|
(temporary / 'manifest.json').write_bytes(raw)
|
|
subprocess.run(['openssl', 'pkeyutl', '-sign', '-rawin', '-inkey', str(private_key), '-in', str(temporary / 'manifest.json'),
|
|
'-out', str(temporary / 'manifest.sig')], check=True, capture_output=True, timeout=15)
|
|
subprocess.run(['openssl', 'pkey', '-in', str(private_key), '-pubout', '-out', str(temporary / 'public.pem')],
|
|
check=True, capture_output=True, timeout=15)
|
|
ENGINE['validate_manifest'](raw, (temporary / 'manifest.sig').read_bytes(), temporary / 'public.pem')
|
|
ENGINE['validate_archive'](data, manifest)
|
|
output.mkdir(mode=0o755) # Existing version directories are immutable.
|
|
try:
|
|
for name in ('manifest.json', 'manifest.sig', 'runtime.zip'):
|
|
ENGINE['atomic_write'](output / name, (temporary / name).read_bytes(), 0o644)
|
|
ENGINE['fsync_dir'](output.parent)
|
|
except BaseException:
|
|
shutil.rmtree(output)
|
|
raise
|
|
return manifest
|
|
|
|
|
|
def main(argv=None):
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('--source', type=Path, default=Path(__file__).resolve().parents[1])
|
|
parser.add_argument('--output', type=Path, required=True)
|
|
parser.add_argument('--private-key', type=Path, required=True)
|
|
parser.add_argument('--version', required=True)
|
|
parser.add_argument('--sequence', type=int, required=True)
|
|
args = parser.parse_args(argv)
|
|
try:
|
|
print(json.dumps(build(args.source, args.output, args.private_key, args.version, args.sequence), indent=2))
|
|
return 0
|
|
except (ValueError, OSError, subprocess.SubprocessError) as exc:
|
|
print('hydra-release: ' + str(exc), file=sys.stderr)
|
|
return 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
raise SystemExit(main())
|