Files
Hydra-OS/dev/release.py
T

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())