feat: assemble an installable Gentoo live image in an exported rootfs
This commit is contained in:
+135
-9
@@ -9,6 +9,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -190,10 +191,11 @@ if path.exists():
|
||||
|
||||
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())
|
||||
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):
|
||||
@@ -209,6 +211,13 @@ if path.exists():
|
||||
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 . 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':
|
||||
if not (self.work / 'artifacts/packages.json').is_file():
|
||||
return False
|
||||
@@ -295,6 +304,9 @@ if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
|
||||
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
|
||||
fi
|
||||
cp -a /hydra-source/image/portage/. /etc/portage/
|
||||
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
|
||||
@@ -311,19 +323,135 @@ chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
|
||||
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']
|
||||
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')
|
||||
script = package_job_script([*command, *atoms], inputs=inputs)
|
||||
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
|
||||
(self.work / 'artifacts/packages.json').write_text(inventory)
|
||||
self.record('packages', inventory='artifacts/packages.json')
|
||||
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id)
|
||||
|
||||
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 . 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'
|
||||
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 = r"""root=/hydra-output/rootfs
|
||||
if [ -e "$root" ] || [ -L "$root" ]; then
|
||||
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
||||
rm -rf -- "$root"
|
||||
fi
|
||||
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 "$root/boot/vmlinuz-$(python3 /hydra-source/hydra/image.py kernel "$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 not manifest.get('packages') or manifest.get('source_files') != image.source_files(self.source_dir):
|
||||
raise BuildError('Payload inventory/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')}
|
||||
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
|
||||
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
|
||||
|
||||
def iso(self):
|
||||
from . 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')}
|
||||
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
|
||||
|
||||
def status(self):
|
||||
data = self.manifest()
|
||||
@@ -380,8 +508,6 @@ def main(argv=None):
|
||||
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:
|
||||
|
||||
Reference in New Issue
Block a user