Files
Hydra-OS/hydra/build.py
T

393 lines
19 KiB
Python

"""Verified, resumable Gentoo builds in a dedicated rootless Podman store."""
import argparse
import contextlib
import fcntl
import hashlib
import json
import lzma
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import urllib.parse
import urllib.request
class BuildError(RuntimeError):
pass
def package_job_script(command, state='/var/lib/hydra-build', package_db='/var/db/pkg', inputs=''):
"""The contained job owns its log/result even if the outer caller disconnects."""
return f'''import fcntl, json, os, pathlib, subprocess, sys, time
state = pathlib.Path({str(state)!r})
database = pathlib.Path({str(package_db)!r})
state.mkdir(parents=True, exist_ok=True)
lock = (state / 'packages.lock').open('a')
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit(75)
def write(name, data):
temporary = state / (name + '.tmp')
temporary.write_text(json.dumps(data, indent=2) + '\\n')
temporary.replace(state / name)
status = {{'running': True, 'pid': os.getpid(), 'inputs': {inputs!r}, 'started': int(time.time())}}
write('packages.status.json', status)
with (state / 'packages.log').open('a') as log:
try:
result = subprocess.run({command!r}, stdout=log, stderr=subprocess.STDOUT)
code = result.returncode
except OSError as error:
log.write(str(error) + '\\n')
code = 127
if code == 0:
inventory = []
for use in sorted(database.glob('*/*/USE')):
package = use.parent
inventory.append({{'package': str(package.relative_to(database)), 'use': use.read_text().split(),
'build_time': (package / 'BUILD_TIME').read_text().strip() if (package / 'BUILD_TIME').exists() else None}})
write('packages.json', inventory)
status.update(running=False, exit_code=code, finished=int(time.time()))
write('packages.status.json', status)
sys.exit(code)
'''
def verify_hash(path, expected):
digest = hashlib.sha256()
with Path(path).open('rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
if digest.hexdigest() != expected:
raise BuildError(f'SHA256 mismatch: {path}')
def require_signature(status, fingerprint):
valid = [line.split() for line in status.splitlines() if line.startswith('[GNUPG:] VALIDSIG ')]
if not valid or not any(fields[-1].upper() == fingerprint.upper() for fields in valid):
raise BuildError('Required primary signature fingerprint is missing')
if any(token in status for token in ('BADSIG', 'ERRSIG', 'REVKEYSIG', 'EXPKEYSIG')):
raise BuildError('Invalid release signature')
class Builder:
def __init__(self, project, work, cache, podman=None, gpg=None, source_dir=None):
if os.geteuid() == 0:
raise BuildError('Run as the normal user, never host root')
self.project = Path(project).resolve()
self.source_dir = Path(source_dir or project).resolve()
self.work, self.cache = Path(work).resolve(), Path(cache).resolve()
for path in (self.work, self.cache):
if path == self.project or not path.is_relative_to(self.project):
raise BuildError('Work and cache paths must be inside the project, below its root')
if self.work == self.cache or self.work.is_relative_to(self.cache) or self.cache.is_relative_to(self.work):
raise BuildError('Work and cache must be separate directories')
self.podman = podman or shutil.which('podman') or 'podman'
self.gpg = gpg or shutil.which('gpg') or 'gpg'
runtime = Path(os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}'))
identifier = hashlib.sha256(str(self.work).encode()).hexdigest()[:12]
self.runroot = runtime / ('hydra-build-podman' if self.work == self.project / 'build' else f'hydra-build-{identifier}')
self.name = 'hydra-builder-' + identifier
self.image = 'localhost/hydra-stage3:20261011'
self._stage3_layers = {}
def podman_argv(self, *args):
return [self.podman, '--root', str(self.work / 'podman'), '--runroot', str(self.runroot), '--storage-driver', 'overlay', *args]
def container_run_args(self):
return ['run', '--detach', '--name', self.name, '--hostname', 'hydra-builder',
'--network', 'host', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
'--volume', f'{self.source_dir}:/hydra-source:ro', '--volume', f'{self.cache}:/hydra-cache:ro',
self.image, '/bin/sleep', 'infinity']
def run(self, argv, capture=False, check=True):
print('+ ' + ' '.join(str(arg) for arg in argv), file=sys.stderr, flush=True)
result = subprocess.run(argv, text=True, stdout=subprocess.PIPE if capture else None,
stderr=subprocess.PIPE if capture else None)
if check and result.returncode:
raise BuildError(f'Command failed ({result.returncode}): {argv[0]}\n{result.stderr or ""}')
return result
def pod(self, *args, **kwargs):
return self.run(self.podman_argv(*args), **kwargs)
def exec(self, command, capture=False):
return self.pod('exec', self.name, '/bin/bash', '-euc', command, capture=capture)
def inspect(self, kind, name):
result = self.pod(kind, 'inspect', name, capture=True, check=False)
if result.returncode:
raise BuildError(f'{kind} provenance is unavailable; use a fresh work directory')
entries = json.loads(result.stdout)
if not isinstance(entries, list) or len(entries) != 1 or not isinstance(entries[0], dict):
raise BuildError(f'Unexpected {kind} provenance')
return entries[0]
def check_mounts(self, container):
for destination, path, label in (('/hydra-source', self.source_dir, 'source'),
('/hydra-cache', self.cache, 'cache')):
mounts = [item for item in container.get('Mounts', []) if item.get('Destination') == destination]
if len(mounts) != 1 or mounts[0].get('Source') != str(path) or mounts[0].get('RW') is not False:
raise BuildError(f'Existing builder {label} mount differs; use its original checkout/cache or a fresh work directory')
def stage3_layer(self, inputs):
stage = inputs['stage3']
archive = self.cache / Path(urllib.parse.urlparse(stage['url']).path).name
verify_hash(archive, stage['sha256'])
if stage['sha256'] not in self._stage3_layers:
digest = hashlib.sha256()
with lzma.open(archive, 'rb') as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b''):
digest.update(chunk)
self._stage3_layers[stage['sha256']] = 'sha256:' + digest.hexdigest()
return self._stage3_layers[stage['sha256']]
def check_base(self, image, inputs):
expected = self.stage3_layer(inputs)
if image.get('RootFS', {}).get('Layers') != [expected]:
raise BuildError('Existing builder stage3 differs from pinned archive; use a fresh work directory')
return expected
def read_container_file(self, path):
# Podman cp also reads a stopped container, so provenance precedes start.
with tempfile.TemporaryDirectory(prefix='hydra-read-') as directory:
target = Path(directory) / 'data'
result = self.pod('cp', f'{self.name}:{path}', str(target), capture=True, check=False)
return target.read_text() if result.returncode == 0 else None
def check_snapshot(self, inputs, allow_pending=False):
marker = self.read_container_file('/var/db/repos/gentoo/.hydra-snapshot')
if marker is None and allow_pending:
marker = self.read_container_file('/var/db/repos/gentoo/.hydra-snapshot-input')
if marker is None or marker.strip() != inputs['portage']['commit']:
raise BuildError('Existing builder snapshot differs or is unverified; use a fresh work directory')
def active_package_job(self):
if not (self.work / 'podman').is_dir():
return False
result = self.pod('top', self.name, 'hpid', 'args', capture=True, check=False)
if result.returncode == 0 and any(re.search(r'(?:^|\s)(?:\S*/)?emerge(?:\s|$)', line)
for line in result.stdout.splitlines()[1:]):
return True
probe = self.pod('exec', self.name, 'python3', '-c', '''import fcntl, pathlib, sys
path = pathlib.Path('/var/lib/hydra-build/packages.lock')
if path.exists():
with path.open('r') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError:
sys.exit(75)
''', capture=True, check=False)
return probe.returncode == 75
def manifest(self):
path = self.work / 'manifest.json'
return json.loads(path.read_text()) if path.exists() else {}
def input_id(self):
digest = hashlib.sha256()
for path in sorted((self.source_dir / 'image').rglob('*')):
if path.is_file():
digest.update(str(path.relative_to(self.source_dir)).encode())
digest.update(path.read_bytes())
return digest.hexdigest()
def record(self, step, **details):
data = self.manifest()
data[step] = {'complete': True, 'inputs': self.input_id(), **details}
data['builder'] = self.name
path = self.work / 'manifest.json'
temp = path.with_suffix('.tmp')
temp.write_text(json.dumps(data, indent=2) + '\n')
temp.replace(path)
def complete(self, step):
entry = self.manifest().get(step, {})
if not entry.get('complete') or entry.get('inputs') != self.input_id():
return False
if step == 'packages':
if not (self.work / 'artifacts/packages.json').is_file():
return False
return self.complete('prepare')
if step == 'prepare':
try:
container = self.inspect('container', self.name)
self.check_mounts(container)
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
self.check_base(self.inspect('image', container['Image']), inputs)
self.check_snapshot(inputs)
return True
except (BuildError, OSError, ValueError, KeyError, lzma.LZMAError):
return False
if step == 'fetch':
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
try:
for name in ('stage3', 'portage'):
item = inputs[name]
path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name)
verify_hash(path, item['sha256'])
except (OSError, BuildError):
return False
return True
return False
def download(self, url, filename=None):
parsed = urllib.parse.urlparse(url)
if parsed.scheme != 'https':
raise BuildError('Build input URLs must use HTTPS')
target = self.cache / (filename or Path(parsed.path).name)
if not target.exists():
temp = target.with_suffix(target.suffix + '.partial')
print(f'Downloading {url}', flush=True)
with urllib.request.urlopen(url, timeout=120) as response, temp.open('wb') as stream:
shutil.copyfileobj(response, stream)
temp.replace(target)
return target
def fetch(self):
inputs = json.loads((self.source_dir / 'image/sources.json').read_text())
stage = inputs['stage3']
archive = self.download(stage['url'])
verify_hash(archive, stage['sha256'])
snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename'])
verify_hash(snapshot, inputs['portage']['sha256'])
signature = self.download(stage['signature_url'])
keyring = self.download(inputs['keyring_url'])
verify_hash(keyring, inputs['keyring_sha256'])
with tempfile.TemporaryDirectory(prefix='hydra-gpg-', dir=self.work) as home:
result = self.run([self.gpg, '--batch', '--homedir', home, '--no-default-keyring', '--keyring', str(keyring),
'--status-fd', '1', '--verify', str(signature), str(archive)], capture=True)
require_signature(result.stdout, stage['fingerprint'])
(self.work / 'artifacts/stage3-signature.txt').write_text(result.stdout + result.stderr)
self.record('fetch', sources=inputs)
return inputs
def prepare(self):
existing = self.pod('container', 'exists', self.name, capture=True, check=False).returncode == 0
container = self.inspect('container', self.name) if existing else None
if container is not None:
self.check_mounts(container) # Reject another worktree before changing any checkpoint.
if self.active_package_job():
raise BuildError('A contained package job is running; wait for it to finish before preparation')
inputs = self.fetch() # Reverify cached bytes even when resuming.
if container is not None:
layer = self.check_base(self.inspect('image', container['Image']), inputs)
self.check_snapshot(inputs, allow_pending=True)
elif self.pod('image', 'exists', self.image, capture=True, check=False).returncode:
self.pod('import', str(self.cache / Path(urllib.parse.urlparse(inputs['stage3']['url']).path).name), self.image)
layer = self.check_base(self.inspect('image', self.image), inputs)
else:
layer = self.check_base(self.inspect('image', self.image), inputs)
if container is None:
self.pod(*self.container_run_args())
else:
self.pod('start', self.name)
snapshot = inputs['portage']['filename']
# Snapshot extraction happens only inside the container; host paths are read-only.
self.exec(f'''mkdir -p /var/db/repos/gentoo
if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot-input
tar --extract --gzip --file /hydra-cache/{snapshot} --strip-components=1 --directory /var/db/repos/gentoo
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
fi
cp -a /hydra-source/image/portage/. /etc/portage/
if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
fi
getuto
gpg --batch --homedir /etc/portage/gnupg --import /hydra-cache/service-keys.gpg
chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
''')
self.record('prepare', image=container['Image'] if container else self.image,
stage3_sha256=inputs['stage3']['sha256'], stage3_layer=layer,
source_dir=str(self.source_dir), cache_dir=str(self.cache), snapshot=inputs['portage']['commit'])
def packages(self, pretend=False):
self.prepare()
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
if line.strip() and not line.startswith('#')]
import shlex
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100']
if pretend:
self.exec(shlex.join([*command, '--pretend', *atoms]))
return
inputs = self.input_id()
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
script = package_job_script([*command, *atoms], inputs=inputs)
self.exec(shlex.join(['python3', '-c', script]))
if self.input_id() != inputs:
raise BuildError('Image inputs changed during package installation; completion was not recorded')
inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout
(self.work / 'artifacts/packages.json').write_text(inventory)
self.record('packages', inventory='artifacts/packages.json')
def status(self):
data = self.manifest()
for step in ('fetch', 'prepare', 'packages', 'rootfs', 'iso'):
if step in data:
data[step]['recorded_complete'] = data[step].get('complete', False)
data[step]['complete'] = self.complete(step)
data['mutation_lock_held'] = self.build_running()
data['package_job_running'] = self.active_package_job()
data['running'] = data['mutation_lock_held'] or data['package_job_running']
print(json.dumps(data, indent=2))
def build_running(self):
try:
with (self.work / '.hydra-build.lock').open('r') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_SH | fcntl.LOCK_NB)
except BlockingIOError:
return True
except FileNotFoundError:
pass
return False
@contextlib.contextmanager
def locked(self):
self.work.mkdir(parents=True, exist_ok=True)
self.cache.mkdir(parents=True, exist_ok=True)
(self.work / 'artifacts').mkdir(exist_ok=True)
with (self.work / '.hydra-build.lock').open('a') as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError as exc:
raise BuildError('Another Hydra build is running in this work directory') from exc
yield
def main(argv=None):
source = Path(__file__).resolve().parents[1]
# Linked worktrees share project-owned caches/build state in the primary checkout.
common = subprocess.run(['git', '-C', str(source), 'rev-parse', '--path-format=absolute', '--git-common-dir'],
text=True, capture_output=True)
project = Path(common.stdout.strip()).parent if common.returncode == 0 else source
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('step', choices=('fetch', 'prepare', 'packages', 'rootfs', 'iso', 'status'))
parser.add_argument('--work', type=Path, default=project / 'build')
parser.add_argument('--cache', type=Path, default=project / 'cache')
parser.add_argument('--podman')
parser.add_argument('--gpg')
parser.add_argument('--pretend', action='store_true', help='resolve packages without installing')
options = parser.parse_args(argv)
try:
builder = Builder(project, options.work, options.cache, options.podman, options.gpg, source)
if options.step == 'status':
builder.status()
return 0
with builder.locked():
if options.step in ('rootfs', 'iso'):
raise BuildError(f'{options.step} assembly is not configured yet')
if options.step == 'packages':
builder.packages(options.pretend)
else:
getattr(builder, options.step)()
return 0
except (BuildError, OSError, ValueError, lzma.LZMAError) as exc:
print(f'hydra-build: {exc}', file=sys.stderr)
return 1