560 lines
30 KiB
Python
560 lines
30 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 shlex
|
|
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 rootfs_retry_guard_script():
|
|
"""Refuse deletion of a still-mounted export, including nested mountpoints."""
|
|
return r"""mounts=$(findmnt --list --raw --noheadings --output TARGET)
|
|
while IFS= read -r mountpoint; do
|
|
case "$mountpoint" in
|
|
"$root"|"$root/"*) echo 'Refusing to remove a mounted rootfs export' >&2; exit 1 ;;
|
|
esac
|
|
done <<< "$mounts"
|
|
if [ -e "$root" ] || [ -L "$root" ]; then
|
|
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
|
rm -rf -- "$root"
|
|
fi
|
|
"""
|
|
|
|
|
|
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 directory in ('image', 'patches'):
|
|
for path in sorted((self.source_dir / directory).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 in ('rootfs', 'iso'):
|
|
from hydra import image
|
|
if entry.get('source_inputs') != image.source_identity(self.source_dir):
|
|
return False
|
|
if not image.artifacts_valid(self.work / 'artifacts', entry):
|
|
return False
|
|
return self.complete('packages' if step == 'rootfs' else 'rootfs')
|
|
if step == 'packages':
|
|
try:
|
|
self.package_inventory()
|
|
except (BuildError, OSError):
|
|
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 item in [inputs['stage3'], inputs['portage'], *inputs.get('local_packages', [])]:
|
|
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 package_inventory(self):
|
|
from hydra import image
|
|
checksum = self.manifest().get('packages', {}).get('inventory_sha256')
|
|
if not isinstance(checksum, str) or not re.fullmatch('[a-f0-9]{64}', checksum):
|
|
raise BuildError('Package inventory has no recorded checksum')
|
|
try:
|
|
return image.read_package_inventory(self.work / 'artifacts/packages.json', checksum)
|
|
except (image.ImageError, OSError) as error:
|
|
raise BuildError(f'Package inventory validation failed: {error}') from error
|
|
|
|
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'])
|
|
for item in inputs.get('local_packages', []):
|
|
verify_hash(self.download(item['url'], item['filename']), item['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/
|
|
mkdir -p /var/db/repos/hydra /var/cache/distfiles
|
|
cp -a /hydra-source/image/overlay/. /var/db/repos/hydra/
|
|
cp /hydra-cache/brightnessctl-0.5.1.tar.gz /var/cache/distfiles/brightnessctl-0.5.1.tar.gz
|
|
mkdir -p /etc/portage/patches/app-emulation/qemu /etc/portage/patches/app-emulation/virt-viewer
|
|
cp /hydra-source/patches/qemu-virtio-refresh-rate.patch /etc/portage/patches/app-emulation/qemu/10-hydra-refresh.patch
|
|
cp /hydra-source/patches/virt-viewer-hydra-session.patch /etc/portage/patches/app-emulation/virt-viewer/10-hydra-session.patch
|
|
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',
|
|
'--usepkg-exclude=app-emulation/qemu app-emulation/virt-viewer']
|
|
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')
|
|
patches = hashlib.sha256()
|
|
for name in ('qemu-virtio-refresh-rate.patch', 'virt-viewer-hydra-session.patch'):
|
|
patches.update((self.source_dir / 'patches' / name).read_bytes())
|
|
patch_id = patches.hexdigest()
|
|
forced = ['emerge', '--verbose', '--oneshot', '--usepkg=n', '--getbinpkg=n',
|
|
'app-emulation/qemu', 'app-emulation/virt-viewer']
|
|
package_commands = shlex.join([*command, *atoms]) + '\n' + f'''if [ "$(cat /var/lib/hydra-build/patched-source.sha256 2>/dev/null || true)" != {shlex.quote(patch_id)} ]; then
|
|
{shlex.join(forced)}
|
|
printf '%s\\n' {shlex.quote(patch_id)} > /var/lib/hydra-build/patched-source.sha256
|
|
fi'''
|
|
script = package_job_script(['/bin/bash', '-euc', package_commands], 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
|
|
from hydra import image
|
|
try:
|
|
image.validate_package_inventory(json.loads(inventory))
|
|
except (ValueError, image.ImageError) as error:
|
|
raise BuildError(f'Package inventory is malformed: {error}') from error
|
|
target = self.work / 'artifacts/packages.json'
|
|
temporary = target.with_suffix('.tmp')
|
|
temporary.write_text(inventory)
|
|
temporary.replace(target)
|
|
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id,
|
|
inventory_sha256=image.digest(target))
|
|
|
|
def assembly(self, snapshot_digest=None):
|
|
"""A separate rootless container processes an exported copy, never the builder."""
|
|
if snapshot_digest is None:
|
|
state = self.manifest().get('rootfs', {})
|
|
snapshot_digest = state.get('snapshot_sha256')
|
|
if not isinstance(snapshot_digest, str) or not re.fullmatch('[a-f0-9]{64}', snapshot_digest):
|
|
raise BuildError('Verified exported snapshot is required')
|
|
name = self.name + '-image-' + snapshot_digest[:12]
|
|
image = 'localhost/hydra-image:' + snapshot_digest
|
|
exists = self.pod('container', 'exists', name, capture=True, check=False).returncode == 0
|
|
if not exists:
|
|
self.pod('import', str(self.work / 'artifacts/builder-rootfs.tar'), image)
|
|
self.pod('run', '--detach', '--name', name, '--hostname', 'hydra-image-assembler',
|
|
'--network', 'none', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
|
|
'--volume', f'{self.source_dir}:/hydra-source:ro',
|
|
'--volume', f'{self.work}:/hydra-output:rw', image, '/bin/sleep', 'infinity')
|
|
container = self.inspect('container', name)
|
|
for destination, source, writable in (('/hydra-source', self.source_dir, False),
|
|
('/hydra-output', self.work, True)):
|
|
mounts = [mount for mount in container.get('Mounts', []) if mount.get('Destination') == destination]
|
|
if len(mounts) != 1 or mounts[0].get('Source') != str(source) or mounts[0].get('RW') is not writable:
|
|
raise BuildError('Assembly mount provenance differs; refusing to write')
|
|
provenance = self.inspect('image', container['Image'])
|
|
if provenance.get('RootFS', {}).get('Layers') != ['sha256:' + snapshot_digest]:
|
|
raise BuildError('Assembly image does not match the exported builder snapshot')
|
|
if exists:
|
|
self.pod('start', name)
|
|
return name
|
|
|
|
def rootfs(self):
|
|
from hydra import image
|
|
if self.active_package_job():
|
|
raise BuildError('A contained package job is running; wait before export')
|
|
if not self.complete('packages'):
|
|
raise BuildError('Verified completed packages are required before rootfs export')
|
|
if self.complete('rootfs'):
|
|
return
|
|
source_inputs = image.source_identity(self.source_dir)
|
|
self.record('rootfs', complete=False)
|
|
original = self.work / 'artifacts/builder-rootfs.tar'
|
|
if original.is_symlink() or not original.parent.resolve().is_relative_to(self.work):
|
|
raise BuildError('Raw export output must remain inside the dedicated work directory')
|
|
original.touch(mode=0o600, exist_ok=True)
|
|
original.chmod(0o600) # The unsanitized intermediate can contain builder-private trust.
|
|
self.pod('export', '--output', str(original), self.name)
|
|
snapshot = image.digest(original)
|
|
assembler = self.assembly(snapshot)
|
|
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
|
script = 'root=/hydra-output/rootfs\n' + rootfs_retry_guard_script() + r"""mkdir -p "$root"
|
|
touch "$root/.hydra-export-owned"
|
|
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
|
/hydra-source/image/configure-rootfs.sh "$root"
|
|
cp "$(python3 /hydra-source/hydra/image.py kernel-image "$root")" /hydra-output/artifacts/vmlinuz
|
|
cp "$root/boot/initramfs-live.img" /hydra-output/artifacts/initramfs-live.img
|
|
cp "$root/usr/share/hydra/build-manifest.json" /hydra-output/artifacts/rootfs-manifest.json
|
|
# Keep ownership for safe retries outside the shipped payload.
|
|
mksquashfs "$root" /hydra-output/artifacts/squashfs.img -noappend -comp zstd -processors 4 -e boot/initramfs-live.img .hydra-export-owned
|
|
chmod 0644 /hydra-output/artifacts/vmlinuz /hydra-output/artifacts/initramfs-live.img /hydra-output/artifacts/squashfs.img /hydra-output/artifacts/rootfs-manifest.json
|
|
"""
|
|
self.pod('exec', '--env', f'HYDRA_SOURCE_COMMIT={commit}', assembler, '/bin/bash', '-euc', script)
|
|
if image.source_identity(self.source_dir) != source_inputs:
|
|
raise BuildError('Source inputs changed during rootfs assembly; completion not recorded')
|
|
manifest = json.loads((self.work / 'artifacts/rootfs-manifest.json').read_text())
|
|
if manifest.get('packages') != self.package_inventory():
|
|
raise BuildError('Payload package inventory differs from the validated package checkpoint')
|
|
if manifest.get('source_files') != image.source_files(self.source_dir):
|
|
raise BuildError('Payload source manifest is incomplete or stale')
|
|
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
|
('vmlinuz', 'initramfs-live.img', 'squashfs.img', 'rootfs-manifest.json', 'packages.json')}
|
|
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
|
|
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
|
|
|
|
def iso(self):
|
|
from hydra import image
|
|
if not self.complete('rootfs'):
|
|
raise BuildError('Verified rootfs artifacts are required before ISO assembly')
|
|
if self.complete('iso'):
|
|
return
|
|
source_inputs = image.source_identity(self.source_dir)
|
|
assembler = self.assembly()
|
|
self.record('iso', complete=False)
|
|
script = r"""tree=/hydra-output/iso-tree
|
|
[ ! -L "$tree" ] || { echo 'ISO output cannot be a symlink' >&2; exit 1; }
|
|
mkdir -p "$tree/boot/grub" "$tree/LiveOS"
|
|
cp /hydra-output/artifacts/vmlinuz "$tree/boot/vmlinuz"
|
|
cp /hydra-output/artifacts/initramfs-live.img "$tree/boot/initramfs-live.img"
|
|
cp /hydra-output/artifacts/squashfs.img "$tree/LiveOS/squashfs.img"
|
|
cp /hydra-source/image/grub.cfg "$tree/boot/grub/grub.cfg"
|
|
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
|
|
cp /hydra-output/artifacts/packages.json "$tree/packages.json"
|
|
grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS
|
|
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1
|
|
chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt
|
|
"""
|
|
self.pod('exec', assembler, '/bin/bash', '-euc', script)
|
|
inspection = (self.work / 'artifacts/iso-inspection.txt').read_text()
|
|
if 'HYDRA_OS' not in inspection or not re.search(r'El Torito boot img.*UEFI', inspection):
|
|
raise BuildError('ISO does not report the expected volume label and UEFI boot image')
|
|
if image.source_identity(self.source_dir) != source_inputs:
|
|
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
|
|
checksum = image.digest(self.work / 'artifacts/hydra-os.iso')
|
|
(self.work / 'artifacts/hydra-os.iso.sha256').write_text(checksum + ' hydra-os.iso\n')
|
|
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
|
('hydra-os.iso', 'hydra-os.iso.sha256', 'iso-inspection.txt', 'packages.json')}
|
|
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
|
|
|
|
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 == '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
|