feat: assemble an installable Gentoo live image in an exported rootfs

This commit is contained in:
Codex
2026-10-13 04:32:18 +03:00
parent 38914cf67f
commit 0f71430b8e
32 changed files with 874 additions and 10 deletions
+135 -9
View File
@@ -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:
+296
View File
@@ -0,0 +1,296 @@
"""Prepare only exported Gentoo payloads and configure the dedicated guest network."""
import argparse
import hashlib
import ipaddress
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
LABEL = 'HYDRA_OS'
BRIDGE = 'hydrabr0'
SUBNET = ipaddress.ip_network('10.203.0.0/24')
CONNECTION_UUID = 'c84844bd-2955-4a61-9463-5e540140f9a2'
class ImageError(RuntimeError):
pass
def root_path(root, relative, follow_leaf=True):
root = Path(root).resolve()
if root == Path('/') or not root.is_dir():
raise ImageError('Expected an isolated exported rootfs directory')
relative = Path(relative)
if relative.is_absolute() or '..' in relative.parts:
raise ImageError('Payload path must be relative and remain inside the export')
path = root / relative
checked = path if follow_leaf else path.parent
if not checked.resolve().is_relative_to(root):
raise ImageError(f'Payload symlink escapes the export: {relative}')
return path
def remove(root, relative):
path = root_path(root, relative, follow_leaf=False)
if path.is_symlink() or path.is_file():
path.unlink()
elif path.is_dir():
shutil.rmtree(path)
def write(root, relative, content, mode=0o644):
path = root_path(root, relative)
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
path.chmod(mode)
return path
def sanitize_rootfs(root):
"""Erase builder identity/secrets in the exported copy, retaining Gentoo updates."""
for relative in ('etc/portage/gnupg', 'etc/portage/gnupg.hydra-untrusted',
'var/lib/hydra-build', 'root', 'home', 'var/cache', 'var/tmp',
'tmp', 'run', 'var/log', 'var/lib/systemd/random-seed',
'var/lib/NetworkManager', 'etc/NetworkManager/system-connections',
'hydra-source', 'hydra-cache', 'hydra-output'):
remove(root, relative)
for name in root_path(root, 'etc/ssh').glob('ssh_host_*'):
remove(root, str(name.relative_to(root)))
for relative, mode in [('root', 0o700), ('home', 0o755), ('tmp', 0o1777),
('var/tmp', 0o1777), ('run', 0o755), ('var/log', 0o755),
('var/cache', 0o755), ('dev', 0o755), ('proc', 0o755), ('sys', 0o755)]:
path = root_path(root, relative)
path.mkdir(parents=True, exist_ok=True)
path.chmod(mode)
remove(root, 'etc/machine-id')
write(root, 'etc/machine-id', '')
remove(root, 'var/lib/dbus/machine-id')
path = root_path(root, 'var/lib/dbus/machine-id', follow_leaf=False)
path.parent.mkdir(parents=True, exist_ok=True)
path.symlink_to('/etc/machine-id')
write(root, 'etc/hostname', 'hydra-os\n')
write(root, 'etc/hosts', '127.0.0.1 localhost\n::1 localhost\n127.0.1.1 hydra-os\n')
remove(root, 'etc/resolv.conf')
root_path(root, 'etc/resolv.conf', follow_leaf=False).symlink_to('/run/NetworkManager/resolv.conf')
def copy_overlay(root, overlay):
for source in sorted(Path(overlay).rglob('*')):
destination = root_path(root, source.relative_to(overlay))
if source.is_symlink():
raise ImageError('Source overlay must contain ordinary directories/files')
if source.is_dir():
destination.mkdir(parents=True, exist_ok=True)
elif source.is_file():
destination.parent.mkdir(parents=True, exist_ok=True)
shutil.copyfile(source, destination)
destination.chmod(source.stat().st_mode & 0o777)
def kernel_version(root):
modules = root_path(root, 'lib/modules')
versions = sorted(path.name for path in modules.iterdir() if path.is_dir()) if modules.is_dir() else []
if len(versions) != 1 or not re.fullmatch(r'[A-Za-z0-9_.+-]+', versions[0]):
raise ImageError('Exactly one installed payload kernel is required')
if not root_path(root, 'boot/vmlinuz-' + versions[0]).is_file():
raise ImageError('Installed modules have no matching /boot/vmlinuz-VERSION')
return versions[0]
def live_kernel_args():
return [f'root=live:CDLABEL={LABEL}', 'rd.live.image', 'rd.live.overlay.overlayfs=1',
'ro', 'console=tty0', 'console=ttyS0,115200n8']
def source_files(source):
source = Path(source)
result = {}
for directory in ('image', 'hydra', 'bin', 'session', 'patches'):
for path in sorted((source / directory).rglob('*')):
if path.is_file() and '__pycache__' not in path.parts and path.suffix != '.pyc':
result[str(path.relative_to(source))] = digest(path)
return result
def source_identity(source):
return hashlib.sha256(json.dumps(source_files(source), sort_keys=True).encode()).hexdigest()
def payload_manifest(root, source):
packages = []
database = root_path(root, 'var/db/pkg')
for use in sorted(database.glob('*/*/USE')):
package = use.parent
built = package / 'BUILD_TIME'
packages.append({'package': str(package.relative_to(database)), 'use': use.read_text().split(),
'build_time': built.read_text().strip() if built.is_file() else None})
commit = os.environ.get('HYDRA_SOURCE_COMMIT')
if not commit:
git = subprocess.run(['git', '-C', str(source), 'rev-parse', 'HEAD'], text=True, capture_output=True)
commit = git.stdout.strip() if git.returncode == 0 else None
return {'format': 1, 'label': LABEL, 'kernel': kernel_version(root),
'live_kernel_args': live_kernel_args(), 'source_commit': commit,
'source_files': source_files(source), 'sources': json.loads((Path(source) / 'image/sources.json').read_text()),
'packages': packages}
def artifacts_valid(directory, state):
artifacts = state.get('artifacts')
if not isinstance(artifacts, dict) or not artifacts:
return False
for name, expected in artifacts.items():
if not isinstance(name, str) or Path(name).name != name or not isinstance(expected, str):
return False
path = Path(directory) / name
try:
if path.is_symlink() or not path.is_file() or digest(path) != expected:
return False
except OSError:
return False
return True
def configure_rootfs(root, source):
root, source = Path(root), Path(source)
# Refuse incomplete integrations before changing the exported copy.
for name in ('bin/hydra', 'bin/hydra-install', 'hydra/session.py', 'hydra/installer.py'):
if not (source / name).is_file():
raise ImageError(f'Required committed runtime/installer asset is missing: {name}')
version = kernel_version(root)
sanitize_rootfs(root)
copy_overlay(root, source / 'image/rootfs')
write(root, 'etc/hydra/live-mode/dracut.conf', (source / 'image/dracut.conf').read_text())
root_path(root, 'etc/hydra/live-mode/dracut.conf.d').mkdir()
package = root_path(root, 'usr/lib/hydra/hydra')
package.mkdir(parents=True, exist_ok=True)
for module in sorted((source / 'hydra').glob('*.py')):
shutil.copyfile(module, root_path(root, 'usr/lib/hydra/hydra/' + module.name))
for name, module in (('hydra', 'cli'), ('hydra-install', 'installer')):
write(root, 'usr/bin/' + name,
'#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
f'from hydra.{module} import main\nraise SystemExit(main())\n', 0o755)
write(root, 'usr/bin/hydra-session', '#!/bin/sh\nexec /usr/bin/hydra session "$@"\n', 0o755)
write(root, 'usr/bin/hydra-network', '#!/usr/bin/python3\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
'from hydra.image import network_main\nraise SystemExit(network_main())\n', 0o755)
write(root, 'usr/share/hydra/build-manifest.json', json.dumps(payload_manifest(root, source), indent=2) + '\n')
make = root_path(root, 'etc/portage/make.conf')
if make.is_file():
settings = make.read_text()
settings = re.sub(r'(?<=[\s"\'])-(?:usersandbox|sandbox|network-sandbox|ipc-sandbox|pid-sandbox)(?=[\s"\']|$)', '', settings)
make.write_text(settings)
for path in root_path(root, 'etc/sudoers.d').iterdir():
path.chmod(0o440)
return version
def check_network_conflicts(routes, addresses):
try:
if not isinstance(routes, list) or not isinstance(addresses, list):
raise ValueError('Expected JSON arrays')
for route in routes:
dst = route.get('dst')
if dst in ('default', None):
continue
network = ipaddress.ip_network(dst, strict=False)
if network.version != 4:
raise ValueError('Unexpected non-IPv4 route')
if network.overlaps(SUBNET) and not (route.get('dev') == BRIDGE and network.subnet_of(SUBNET)):
raise ImageError(f'Guest subnet {SUBNET} conflicts with route {dst} on {route.get("dev")}')
for interface in addresses:
for addr in interface['addr_info']:
if addr.get('family') != 'inet':
continue
network = ipaddress.ip_network(f'{addr["local"]}/{addr["prefixlen"]}', strict=False)
owned = interface['ifname'] == BRIDGE and addr['local'] == '10.203.0.1' and addr['prefixlen'] == 24
if network.overlaps(SUBNET) and not owned:
raise ImageError(f'Guest subnet {SUBNET} conflicts with address on {interface["ifname"]}')
except (ValueError, KeyError, TypeError, AttributeError) as error:
raise ImageError('Cannot safely interpret IPv4 routes/addresses') from error
def configure_network(run=subprocess.run):
def command(argv, **kwargs):
result = run(argv, text=True, capture_output=True, **kwargs)
if result.returncode:
raise ImageError(f'Network command failed: {argv[0]}: {result.stderr.strip()}')
return result.stdout
routes = json.loads(command(['ip', '-json', '-4', 'route', 'show', 'table', 'all']))
addresses = json.loads(command(['ip', '-json', '-4', 'address', 'show']))
check_network_conflicts(routes, addresses)
probe = run(['nmcli', '--get-values', 'connection.uuid,connection.type,connection.interface-name,ipv4.method,ipv4.addresses',
'connection', 'show', 'uuid', CONNECTION_UUID], text=True, capture_output=True)
exists = probe.returncode == 0
if exists and probe.stdout.strip().splitlines() != [CONNECTION_UUID, 'bridge', BRIDGE, 'manual', '10.203.0.1/24']:
raise ImageError('Hydra bridge connection exists with unexpected configuration')
if not exists and probe.returncode != 10:
raise ImageError('Cannot query NetworkManager connections')
links = json.loads(command(['ip', '-json', '-details', 'link', 'show']))
for link in links:
if link.get('ifname') == BRIDGE and (not exists or link.get('linkinfo', {}).get('info_kind') != 'bridge'):
raise ImageError('Refusing to replace an existing interface named hydrabr0')
if not exists:
command(['nmcli', 'connection', 'add', 'type', 'bridge', 'con-name', 'hydra-guest',
'ifname', BRIDGE, 'connection.uuid', CONNECTION_UUID,
'connection.autoconnect', 'no', 'bridge.stp', 'no',
'ipv4.method', 'manual', 'ipv4.addresses', '10.203.0.1/24',
'ipv4.never-default', 'yes', 'ipv6.method', 'disabled'])
# Activation is always gated by the fresh route/address check, including restarts.
command(['nmcli', 'connection', 'up', 'uuid', CONNECTION_UUID])
command(['sysctl', '-w', 'net.ipv4.ip_forward=1'])
table = run(['nft', 'list', 'table', 'ip', 'hydra_nat'], text=True, capture_output=True)
rules = ('delete table ip hydra_nat\n' if table.returncode == 0 else '') + '''table ip hydra_nat {
chain postrouting {
type nat hook postrouting priority srcnat; policy accept;
ip saddr 10.203.0.0/24 oifname != "hydrabr0" masquerade
}
}
'''
command(['nft', '-f', '-'], input=rules)
def network_main():
try:
configure_network()
except (ImageError, OSError, ValueError) as error:
print(f'hydra-network: {error}', file=sys.stderr)
return 1
return 0
def digest(path):
value = hashlib.sha256()
with Path(path).open('rb') as stream:
for block in iter(lambda: stream.read(1024 * 1024), b''):
value.update(block)
return value.hexdigest()
def main(argv=None):
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('step', choices=('configure', 'kernel', 'network'))
parser.add_argument('root', nargs='?', type=Path)
parser.add_argument('--source', type=Path)
options = parser.parse_args(argv)
try:
if options.step == 'network':
return network_main()
if options.root is None:
raise ImageError('An isolated rootfs directory is required')
if options.step == 'kernel':
print(kernel_version(options.root))
else:
if options.source is None:
raise ImageError('Source checkout is required')
configure_rootfs(options.root, options.source)
except (ImageError, OSError, ValueError) as error:
print(f'hydra-image: {error}', file=sys.stderr)
return 1
return 0
if __name__ == '__main__':
raise SystemExit(main())