From 0f71430b8eebab38dc9974ea40228dc2831de06b Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 04:32:18 +0300 Subject: [PATCH] feat: assemble an installable Gentoo live image in an exported rootfs --- hydra/build.py | 144 ++++++++- hydra/image.py | 296 ++++++++++++++++++ image/configure-rootfs.sh | 33 ++ image/dracut.conf | 6 + image/grub.cfg | 12 + image/packages | 1 + image/portage/make.conf | 2 +- image/rootfs/etc/default/grub | 3 + image/rootfs/etc/dracut.conf.d/50-hydra.conf | 3 + image/rootfs/etc/greetd/config.toml | 5 + image/rootfs/etc/greetd/live.toml | 8 + image/rootfs/etc/hydra/defaults.toml | 5 + image/rootfs/etc/hydra/dnsmasq.conf | 11 + image/rootfs/etc/hydra/live-mode/active | 1 + .../hydra/live-mode/remove-on-install.json | 7 + image/rootfs/etc/locale.conf | 1 + image/rootfs/etc/locale.gen | 2 + image/rootfs/etc/motd | 5 + image/rootfs/etc/portage/bashrc | 6 + image/rootfs/etc/qemu/bridge.conf | 1 + image/rootfs/etc/sudoers.d/50-hydra-wheel | 1 + image/rootfs/etc/sudoers.d/90-hydra-live | 1 + .../rootfs/etc/sysctl.d/50-hydra-network.conf | 1 + .../etc/systemd/logind.conf.d/50-hydra.conf | 3 + .../getty@tty2.service.d/90-hydra-live.conf | 3 + .../greetd.service.d/90-hydra-live.conf | 3 + .../system/hydra-bridge-permissions.service | 8 + .../etc/systemd/system/hydra-dnsmasq.service | 12 + .../etc/systemd/system/hydra-network.service | 12 + .../90-hydra-live.conf | 3 + .../usr/libexec/hydra-bridge-permissions | 7 + tests/test_image.py | 278 ++++++++++++++++ 32 files changed, 874 insertions(+), 10 deletions(-) create mode 100644 hydra/image.py create mode 100755 image/configure-rootfs.sh create mode 100644 image/dracut.conf create mode 100644 image/grub.cfg create mode 100644 image/rootfs/etc/default/grub create mode 100644 image/rootfs/etc/dracut.conf.d/50-hydra.conf create mode 100644 image/rootfs/etc/greetd/config.toml create mode 100644 image/rootfs/etc/greetd/live.toml create mode 100644 image/rootfs/etc/hydra/defaults.toml create mode 100644 image/rootfs/etc/hydra/dnsmasq.conf create mode 100644 image/rootfs/etc/hydra/live-mode/active create mode 100644 image/rootfs/etc/hydra/live-mode/remove-on-install.json create mode 100644 image/rootfs/etc/locale.conf create mode 100644 image/rootfs/etc/locale.gen create mode 100644 image/rootfs/etc/motd create mode 100644 image/rootfs/etc/portage/bashrc create mode 100644 image/rootfs/etc/qemu/bridge.conf create mode 100644 image/rootfs/etc/sudoers.d/50-hydra-wheel create mode 100644 image/rootfs/etc/sudoers.d/90-hydra-live create mode 100644 image/rootfs/etc/sysctl.d/50-hydra-network.conf create mode 100644 image/rootfs/etc/systemd/logind.conf.d/50-hydra.conf create mode 100644 image/rootfs/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf create mode 100644 image/rootfs/etc/systemd/system/greetd.service.d/90-hydra-live.conf create mode 100644 image/rootfs/etc/systemd/system/hydra-bridge-permissions.service create mode 100644 image/rootfs/etc/systemd/system/hydra-dnsmasq.service create mode 100644 image/rootfs/etc/systemd/system/hydra-network.service create mode 100644 image/rootfs/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf create mode 100755 image/rootfs/usr/libexec/hydra-bridge-permissions create mode 100644 tests/test_image.py diff --git a/hydra/build.py b/hydra/build.py index 661d5b2..2bc094f 100644 --- a/hydra/build.py +++ b/hydra/build.py @@ -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: diff --git a/hydra/image.py b/hydra/image.py new file mode 100644 index 0000000..f304e99 --- /dev/null +++ b/hydra/image.py @@ -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()) diff --git a/image/configure-rootfs.sh b/image/configure-rootfs.sh new file mode 100755 index 0000000..c59c102 --- /dev/null +++ b/image/configure-rootfs.sh @@ -0,0 +1,33 @@ +#!/bin/bash +set -euo pipefail +if [[ $# != 1 || "$1" != /hydra-output/rootfs ]]; then + echo "configure-rootfs: expected the isolated /hydra-output/rootfs export" >&2 + exit 1 +fi +root=$1 +python3 /hydra-source/hydra/image.py configure "$root" --source /hydra-source +for group in wheel kvm video render audio; do + chroot "$root" getent group "$group" >/dev/null || chroot "$root" groupadd -r "$group" +done +if ! chroot "$root" getent passwd hydra-live >/dev/null; then + chroot "$root" useradd --uid 1000 --create-home --home-dir /home/hydra-live --shell /bin/bash --groups wheel,kvm,video,render,audio hydra-live +fi +chroot "$root" usermod --lock hydra-live +chroot "$root" usermod --lock root +chroot "$root" locale-gen +ln -snf /usr/share/zoneinfo/Europe/Moscow "$root/etc/localtime" +# Enable the ordinary per-user sound services without starting a user manager. +systemctl --root="$root" --global enable pipewire.socket wireplumber.service +# Set rootfs permissions now; capabilities are regenerated in the boot namespace. +chroot "$root" chown root:kvm /usr/libexec/qemu-bridge-helper +chroot "$root" chmod 0750 /usr/libexec/qemu-bridge-helper +chroot "$root" visudo --check +systemctl --root="$root" enable NetworkManager.service greetd.service hydra-network.service hydra-dnsmasq.service hydra-bridge-permissions.service getty@tty2.service serial-getty@ttyS0.service +systemctl --root="$root" set-default graphical.target +# This is a separate rootfs inside the assembly container, never the host root. +mount --bind /dev "$root/dev" +mount -t proc proc "$root/proc" +mount --bind /sys "$root/sys" +trap 'umount "$root/sys" "$root/proc" "$root/dev"' EXIT +kernel=$(python3 /hydra-source/hydra/image.py kernel "$root") +chroot "$root" dracut --force --no-hostonly --no-hostonly-cmdline --conf /etc/hydra/live-mode/dracut.conf --confdir /etc/hydra/live-mode/dracut.conf.d --add dmsquash-live /boot/initramfs-live.img "$kernel" diff --git a/image/dracut.conf b/image/dracut.conf new file mode 100644 index 0000000..6b04673 --- /dev/null +++ b/image/dracut.conf @@ -0,0 +1,6 @@ +hostonly="no" +hostonly_cmdline="no" +add_dracutmodules+=" dmsquash-live " +filesystems+=" squashfs overlay iso9660 vfat ext4 " +add_drivers+=" vmd nvme xhci_pci thunderbolt usb_storage uas sd_mod virtio_pci virtio_blk virtio_scsi virtio_net virtio_gpu i915 loop squashfs overlay sr_mod ahci " +compress="zstd" diff --git a/image/grub.cfg b/image/grub.cfg new file mode 100644 index 0000000..22e87ff --- /dev/null +++ b/image/grub.cfg @@ -0,0 +1,12 @@ +set default=0 +set timeout=5 +insmod all_video +insmod serial +serial --unit=0 --speed=115200 +terminal_input console serial +terminal_output console serial +menuentry "HydraOS live (volatile host root)" { + search --no-floppy --label HYDRA_OS --set=root + linux /boot/vmlinuz root=live:CDLABEL=HYDRA_OS rd.live.image rd.live.overlay.overlayfs=1 ro console=tty0 console=ttyS0,115200n8 + initrd /boot/initramfs-live.img +} diff --git a/image/packages b/image/packages index ff3e66e..b5772a2 100644 --- a/image/packages +++ b/image/packages @@ -28,3 +28,4 @@ dev-libs/libisoburn dev-lang/python sec-keys/openpgp-keys-gentoo-release sec-keys/openpgp-keys-gentoo-auth +sys-apps/brightnessctl diff --git a/image/portage/make.conf b/image/portage/make.conf index bf97b4b..2254324 100644 --- a/image/portage/make.conf +++ b/image/portage/make.conf @@ -7,7 +7,7 @@ MAKEOPTS="-j4" EMERGE_DEFAULT_OPTS="--jobs=1 --getbinpkg --binpkg-respect-use=y --autounmask=n" FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox" ACCEPT_LICENSE="@FREE @BINARY-REDISTRIBUTABLE linux-fw-redistributable no-source-code Intel-SDP" -VIDEO_CARDS="intel" +VIDEO_CARDS="intel virgl" USE="X dbus wayland opengl alsa" QEMU_SOFTMMU_TARGETS="x86_64" QEMU_USER_TARGETS="" diff --git a/image/rootfs/etc/default/grub b/image/rootfs/etc/default/grub new file mode 100644 index 0000000..0cc5428 --- /dev/null +++ b/image/rootfs/etc/default/grub @@ -0,0 +1,3 @@ +GRUB_DISTRIBUTOR="HydraOS" +GRUB_TIMEOUT=5 +GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8" diff --git a/image/rootfs/etc/dracut.conf.d/50-hydra.conf b/image/rootfs/etc/dracut.conf.d/50-hydra.conf new file mode 100644 index 0000000..7adf3af --- /dev/null +++ b/image/rootfs/etc/dracut.conf.d/50-hydra.conf @@ -0,0 +1,3 @@ +hostonly="no" +hostonly_cmdline="no" +omit_dracutmodules+=" dmsquash-live " diff --git a/image/rootfs/etc/greetd/config.toml b/image/rootfs/etc/greetd/config.toml new file mode 100644 index 0000000..38a6b52 --- /dev/null +++ b/image/rootfs/etc/greetd/config.toml @@ -0,0 +1,5 @@ +[terminal] +vt = 1 +[default_session] +command = "/usr/bin/agreety --cmd /usr/bin/hydra-session" +user = "greetd" diff --git a/image/rootfs/etc/greetd/live.toml b/image/rootfs/etc/greetd/live.toml new file mode 100644 index 0000000..e1dce05 --- /dev/null +++ b/image/rootfs/etc/greetd/live.toml @@ -0,0 +1,8 @@ +[terminal] +vt = 1 +[default_session] +command = "/usr/bin/agreety --cmd /usr/bin/hydra-session" +user = "greetd" +[initial_session] +command = "/usr/bin/hydra-session" +user = "hydra-live" diff --git a/image/rootfs/etc/hydra/defaults.toml b/image/rootfs/etc/hydra/defaults.toml new file mode 100644 index 0000000..d007199 --- /dev/null +++ b/image/rootfs/etc/hydra/defaults.toml @@ -0,0 +1,5 @@ +network = "bridge" +bridge = "hydrabr0" +width = 1920 +height = 1080 +refresh_millihz = 144000 diff --git a/image/rootfs/etc/hydra/dnsmasq.conf b/image/rootfs/etc/hydra/dnsmasq.conf new file mode 100644 index 0000000..f671b10 --- /dev/null +++ b/image/rootfs/etc/hydra/dnsmasq.conf @@ -0,0 +1,11 @@ +interface=hydrabr0 +bind-dynamic +except-interface=lo +listen-address=10.203.0.1 +dhcp-range=10.203.0.20,10.203.0.254,255.255.255.0,12h +dhcp-option=option:router,10.203.0.1 +dhcp-option=option:dns-server,10.203.0.1 +dhcp-leasefile=/var/lib/hydra/dnsmasq.leases +resolv-file=/run/NetworkManager/resolv.conf +domain-needed +bogus-priv diff --git a/image/rootfs/etc/hydra/live-mode/active b/image/rootfs/etc/hydra/live-mode/active new file mode 100644 index 0000000..f257c8f --- /dev/null +++ b/image/rootfs/etc/hydra/live-mode/active @@ -0,0 +1 @@ +HydraOS live medium: changes to the host root are not persistent. diff --git a/image/rootfs/etc/hydra/live-mode/remove-on-install.json b/image/rootfs/etc/hydra/live-mode/remove-on-install.json new file mode 100644 index 0000000..bf53bcc --- /dev/null +++ b/image/rootfs/etc/hydra/live-mode/remove-on-install.json @@ -0,0 +1,7 @@ +[ + "/etc/greetd/live.toml", + "/etc/sudoers.d/90-hydra-live", + "/etc/systemd/system/greetd.service.d/90-hydra-live.conf", + "/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf", + "/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf" +] diff --git a/image/rootfs/etc/locale.conf b/image/rootfs/etc/locale.conf new file mode 100644 index 0000000..01ec548 --- /dev/null +++ b/image/rootfs/etc/locale.conf @@ -0,0 +1 @@ +LANG=en_US.UTF-8 diff --git a/image/rootfs/etc/locale.gen b/image/rootfs/etc/locale.gen new file mode 100644 index 0000000..0c8fc3f --- /dev/null +++ b/image/rootfs/etc/locale.gen @@ -0,0 +1,2 @@ +en_US.UTF-8 UTF-8 +ru_RU.UTF-8 UTF-8 diff --git a/image/rootfs/etc/motd b/image/rootfs/etc/motd new file mode 100644 index 0000000..b194864 --- /dev/null +++ b/image/rootfs/etc/motd @@ -0,0 +1,5 @@ +HydraOS: fullscreen VMs with an ordinary Gentoo host underneath. +Live host changes are volatile. Use sudo hydra-install for the guarded installer. +Use hydra --help, nmcli, or nmtui to configure VMs and network connections. +Ctrl+Alt+F2 opens local recovery; return to the graphical session on Ctrl+Alt+F1. +Guest bridge: 10.203.0.0/24. Routed LAN access requires a route on your LAN router. diff --git a/image/rootfs/etc/portage/bashrc b/image/rootfs/etc/portage/bashrc new file mode 100644 index 0000000..bb4878d --- /dev/null +++ b/image/rootfs/etc/portage/bashrc @@ -0,0 +1,6 @@ +# Preserve the scoped bridge-helper permission after ordinary QEMU updates. +post_pkg_postinst() { + if [[ ${CATEGORY}/${PN} == app-emulation/qemu ]]; then + "${EROOT}usr/libexec/hydra-bridge-permissions" || die "Hydra bridge permissions failed" + fi +} diff --git a/image/rootfs/etc/qemu/bridge.conf b/image/rootfs/etc/qemu/bridge.conf new file mode 100644 index 0000000..c9445b2 --- /dev/null +++ b/image/rootfs/etc/qemu/bridge.conf @@ -0,0 +1 @@ +allow hydrabr0 diff --git a/image/rootfs/etc/sudoers.d/50-hydra-wheel b/image/rootfs/etc/sudoers.d/50-hydra-wheel new file mode 100644 index 0000000..40695bb --- /dev/null +++ b/image/rootfs/etc/sudoers.d/50-hydra-wheel @@ -0,0 +1 @@ +%wheel ALL=(ALL:ALL) ALL diff --git a/image/rootfs/etc/sudoers.d/90-hydra-live b/image/rootfs/etc/sudoers.d/90-hydra-live new file mode 100644 index 0000000..ed7e10f --- /dev/null +++ b/image/rootfs/etc/sudoers.d/90-hydra-live @@ -0,0 +1 @@ +hydra-live ALL=(ALL:ALL) NOPASSWD: ALL diff --git a/image/rootfs/etc/sysctl.d/50-hydra-network.conf b/image/rootfs/etc/sysctl.d/50-hydra-network.conf new file mode 100644 index 0000000..4234b54 --- /dev/null +++ b/image/rootfs/etc/sysctl.d/50-hydra-network.conf @@ -0,0 +1 @@ +net.ipv4.ip_forward = 1 diff --git a/image/rootfs/etc/systemd/logind.conf.d/50-hydra.conf b/image/rootfs/etc/systemd/logind.conf.d/50-hydra.conf new file mode 100644 index 0000000..39ad7fc --- /dev/null +++ b/image/rootfs/etc/systemd/logind.conf.d/50-hydra.conf @@ -0,0 +1,3 @@ +[Login] +KillUserProcesses=no +UserStopDelaySec=infinity diff --git a/image/rootfs/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf b/image/rootfs/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf new file mode 100644 index 0000000..1ddcd83 --- /dev/null +++ b/image/rootfs/etc/systemd/system/getty@tty2.service.d/90-hydra-live.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin hydra-live --noclear %I $TERM diff --git a/image/rootfs/etc/systemd/system/greetd.service.d/90-hydra-live.conf b/image/rootfs/etc/systemd/system/greetd.service.d/90-hydra-live.conf new file mode 100644 index 0000000..5f7f3e8 --- /dev/null +++ b/image/rootfs/etc/systemd/system/greetd.service.d/90-hydra-live.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=/usr/bin/greetd --config /etc/greetd/live.toml diff --git a/image/rootfs/etc/systemd/system/hydra-bridge-permissions.service b/image/rootfs/etc/systemd/system/hydra-bridge-permissions.service new file mode 100644 index 0000000..93084f6 --- /dev/null +++ b/image/rootfs/etc/systemd/system/hydra-bridge-permissions.service @@ -0,0 +1,8 @@ +[Unit] +Description=Restrict QEMU bridge helper to HydraOS VM users +Before=greetd.service +[Service] +Type=oneshot +ExecStart=/usr/libexec/hydra-bridge-permissions +[Install] +WantedBy=multi-user.target diff --git a/image/rootfs/etc/systemd/system/hydra-dnsmasq.service b/image/rootfs/etc/systemd/system/hydra-dnsmasq.service new file mode 100644 index 0000000..1a774fd --- /dev/null +++ b/image/rootfs/etc/systemd/system/hydra-dnsmasq.service @@ -0,0 +1,12 @@ +[Unit] +Description=DHCP and DNS for HydraOS guests +Requires=hydra-network.service +PartOf=hydra-network.service +After=hydra-network.service NetworkManager.service +[Service] +Type=simple +StateDirectory=hydra +ExecStart=/usr/sbin/dnsmasq --keep-in-foreground --conf-file=/etc/hydra/dnsmasq.conf +Restart=on-failure +[Install] +WantedBy=multi-user.target diff --git a/image/rootfs/etc/systemd/system/hydra-network.service b/image/rootfs/etc/systemd/system/hydra-network.service new file mode 100644 index 0000000..9023361 --- /dev/null +++ b/image/rootfs/etc/systemd/system/hydra-network.service @@ -0,0 +1,12 @@ +[Unit] +Description=HydraOS guest bridge and Internet NAT +Requires=NetworkManager.service +PartOf=NetworkManager.service +After=NetworkManager.service +Before=hydra-dnsmasq.service +[Service] +Type=oneshot +RemainAfterExit=yes +ExecStart=/usr/bin/hydra-network +[Install] +WantedBy=multi-user.target diff --git a/image/rootfs/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf b/image/rootfs/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf new file mode 100644 index 0000000..1ddcd83 --- /dev/null +++ b/image/rootfs/etc/systemd/system/serial-getty@ttyS0.service.d/90-hydra-live.conf @@ -0,0 +1,3 @@ +[Service] +ExecStart= +ExecStart=-/sbin/agetty --autologin hydra-live --noclear %I $TERM diff --git a/image/rootfs/usr/libexec/hydra-bridge-permissions b/image/rootfs/usr/libexec/hydra-bridge-permissions new file mode 100755 index 0000000..b7d208a --- /dev/null +++ b/image/rootfs/usr/libexec/hydra-bridge-permissions @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +helper=/usr/libexec/qemu-bridge-helper +[ -f "$helper" ] && [ ! -L "$helper" ] +chown root:kvm "$helper" +chmod 0750 "$helper" +setcap cap_net_admin+ep "$helper" diff --git a/tests/test_image.py b/tests/test_image.py new file mode 100644 index 0000000..7eff0c2 --- /dev/null +++ b/tests/test_image.py @@ -0,0 +1,278 @@ +import importlib.util +import json +from pathlib import Path +import subprocess +import tempfile +import tomllib +import unittest + +try: + from hydra import image +except ImportError: + image = None + + +class ImageTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(image, 'image pipeline is missing') + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) / 'rootfs' + self.root.mkdir() + + def file(self, relative, content='preserved'): + path = self.root / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + def kernel(self, version='6.12.58-gentoo-dist'): + (self.root / 'lib/modules' / version).mkdir(parents=True) + self.file('boot/vmlinuz-' + version, 'kernel') + return version + + def test_kernel_from_payload_not_running_host(self): + self.kernel() + self.assertEqual(image.kernel_version(self.root), '6.12.58-gentoo-dist') + + def test_ambiguous_or_missing_kernel_refused(self): + with self.assertRaises(image.ImageError): + image.kernel_version(self.root) + self.kernel('6.1-one') + self.kernel('6.1-two') + with self.assertRaises(image.ImageError): + image.kernel_version(self.root) + + def test_module_directory_without_kernel_refused(self): + self.kernel() + (self.root / 'boot/vmlinuz-6.12.58-gentoo-dist').unlink() + with self.assertRaises(image.ImageError): + image.kernel_version(self.root) + + def test_export_sanitization_preserves_update_inputs(self): + preserved = ['var/db/pkg/sys-apps/portage-3/USE', 'var/lib/portage/world', + 'var/db/repos/gentoo/profiles/repo_name', 'usr/share/openpgp-keys/gentoo-release.asc', + 'etc/portage/patches/app-emulation/qemu/patch.patch', 'etc/portage/make.conf'] + removed = ['etc/portage/gnupg/private-keys-v1.d/key', 'etc/portage/gnupg.hydra-untrusted/key', + 'var/lib/hydra-build/packages.log', 'root/.ssh/id_ed25519', 'root/.bash_history', + 'var/cache/binpkgs/package.tbz2', 'var/tmp/portage/build/log', 'run/container-state', + 'var/log/emerge.log', 'var/lib/systemd/random-seed', 'hydra-source/private', 'hydra-cache/private'] + for name in preserved + removed: + self.file(name) + self.file('etc/machine-id', 'builder-id') + self.file('etc/resolv.conf', 'nameserver 127.0.0.1') + image.sanitize_rootfs(self.root) + for name in preserved: + self.assertEqual((self.root / name).read_text(), 'preserved', name) + for name in removed: + self.assertFalse((self.root / name).exists(), name) + self.assertEqual((self.root / 'etc/machine-id').read_text(), '') + self.assertEqual((self.root / 'etc/hostname').read_text(), 'hydra-os\n') + self.assertTrue((self.root / 'etc/resolv.conf').is_symlink()) + + def test_sanitization_cannot_follow_exported_symlink_to_host(self): + outside = Path(self.tmp.name) / 'host' + outside.mkdir() + secret = outside / 'machine-id' + secret.write_text('host-id') + (self.root / 'etc').symlink_to(outside) + with self.assertRaises(image.ImageError): + image.sanitize_rootfs(self.root) + self.assertEqual(secret.read_text(), 'host-id') + + def test_installed_configuration_restores_package_sandbox_defaults(self): + self.kernel() + self.file('etc/portage/make.conf', 'FEATURES="getbinpkg binpkg-request-signature -usersandbox -sandbox -network-sandbox -ipc-sandbox -pid-sandbox"\n') + image.configure_rootfs(self.root, Path(__file__).resolve().parents[1]) + features = (self.root / 'etc/portage/make.conf').read_text() + for option in ('-usersandbox', '-sandbox', '-network-sandbox', '-ipc-sandbox', '-pid-sandbox'): + self.assertNotIn(option, features) + self.assertIn('binpkg-request-signature', features) + self.assertEqual((self.root / 'usr/share/hydra/build-manifest.json').is_file(), True) + + def test_rootfs_configuration_separates_live_overrides_and_installed_auth(self): + source = Path(__file__).resolve().parents[1] + image.copy_overlay(self.root, source / 'image/rootfs') + installed = tomllib.loads((self.root / 'etc/greetd/config.toml').read_text()) + live = tomllib.loads((self.root / 'etc/greetd/live.toml').read_text()) + self.assertNotIn('initial_session', installed) + self.assertEqual(live['initial_session']['user'], 'hydra-live') + manifest = json.loads((self.root / 'etc/hydra/live-mode/remove-on-install.json').read_text()) + for name in manifest: + self.assertTrue((self.root / name.lstrip('/')).is_file(), name) + self.assertIn('/etc/sudoers.d/90-hydra-live', manifest) + self.assertIn('/etc/greetd/live.toml', manifest) + self.assertNotIn('root=live:', (self.root / 'etc/dracut.conf.d/50-hydra.conf').read_text()) + defaults = tomllib.loads((self.root / 'etc/hydra/defaults.toml').read_text()) + self.assertEqual(defaults, dict(network='bridge', bridge='hydrabr0', width=1920, height=1080, refresh_millihz=144000)) + + def test_overlay_cannot_write_through_host_symlink(self): + outside = Path(self.tmp.name) / 'host' + outside.mkdir() + (self.root / 'etc').symlink_to(outside) + with self.assertRaises(image.ImageError): + image.copy_overlay(self.root, Path(__file__).resolve().parents[1] / 'image/rootfs') + self.assertEqual(list(outside.iterdir()), []) + + def test_live_kernel_args_match_label_and_never_copy_payload_to_ram(self): + args = image.live_kernel_args() + self.assertIn('root=live:CDLABEL=HYDRA_OS', args) + self.assertIn('rd.live.overlay.overlayfs=1', args) + self.assertNotIn('rd.live.ram=1', args) + cfg = (Path(__file__).resolve().parents[1] / 'image/grub.cfg').read_text() + linux = next(line.strip() for line in cfg.splitlines() if line.strip().startswith('linux ')) + self.assertEqual(linux.split()[2:], args) + + def test_manifest_records_actual_kernel_packages_and_source_content(self): + self.kernel() + self.file('var/db/pkg/gui-wm/labwc-0.20.0/USE', 'X png') + self.file('var/db/pkg/gui-wm/labwc-0.20.0/BUILD_TIME', '123') + source = Path(__file__).resolve().parents[1] + result = image.payload_manifest(self.root, source) + self.assertEqual(result['kernel'], '6.12.58-gentoo-dist') + self.assertEqual(result['packages'], [{'package': 'gui-wm/labwc-0.20.0', 'use': ['X', 'png'], 'build_time': '123'}]) + self.assertIn('hydra/session.py', result['source_files']) + self.assertIn('image/rootfs/etc/hydra/defaults.toml', result['source_files']) + + def test_artifact_checkpoint_checks_bytes_not_flags(self): + path = self.file('artifacts/squashfs.img', 'actual-payload') + state = {'artifacts': {'squashfs.img': image.digest(path)}} + self.assertTrue(image.artifacts_valid(path.parent, state)) + path.write_text('changed-payload') + self.assertFalse(image.artifacts_valid(path.parent, state)) + path.unlink() + self.assertFalse(image.artifacts_valid(path.parent, state)) + self.assertFalse(image.artifacts_valid(path.parent, {'artifacts': {}})) + self.assertFalse(image.artifacts_valid(path.parent, {'artifacts': {'../escape': '0' * 64}})) + + +class ImageBuildTests(unittest.TestCase): + def setUp(self): + from hydra.build import Builder + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.project = Path(self.tmp.name) + self.builder = Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman') + + def test_changed_canonical_patch_invalidates_package_input_identity(self): + patches = self.project / 'patches' + patches.mkdir() + patch = patches / 'qemu-virtio-refresh-rate.patch' + patch.write_text('old patch') + before = self.builder.input_id() + patch.write_text('new patch') + self.assertNotEqual(self.builder.input_id(), before) + + def test_running_package_job_blocks_export(self): + from hydra.build import BuildError + self.builder.active_package_job = lambda: True + self.builder.pod = lambda *args, **kw: self.fail('Running builder was modified or exported') + self.assertTrue(callable(getattr(self.builder, 'rootfs', None)), 'rootfs pipeline is missing') + with self.assertRaisesRegex(BuildError, 'running'): + self.builder.rootfs() + + def test_unverified_packages_block_export(self): + from hydra.build import BuildError + self.builder.active_package_job = lambda: False + self.builder.pod = lambda *args, **kw: self.fail('Unverified builder was exported') + self.assertTrue(callable(getattr(self.builder, 'rootfs', None)), 'rootfs pipeline is missing') + with self.assertRaises(BuildError): + self.builder.rootfs() + + def test_iso_requires_checked_rootfs_artifacts(self): + from hydra.build import BuildError + self.builder.pod = lambda *args, **kw: self.fail('ISO build started without checked rootfs') + self.assertTrue(callable(getattr(self.builder, 'iso', None)), 'iso pipeline is missing') + with self.assertRaises(BuildError): + self.builder.iso() + + +class NetworkTests(unittest.TestCase): + def setUp(self): + self.assertIsNotNone(image, 'network preparation is missing') + + def test_default_route_does_not_conflict(self): + image.check_network_conflicts([{'dst': 'default', 'gateway': '192.168.1.1', 'dev': 'eth0'}, + {'dst': '192.168.1.0/24', 'dev': 'eth0'}], []) + + def test_overlapping_lan_vpn_address_or_route_fails_closed(self): + for routes, addresses in [([{'dst': '10.203.0.0/16', 'dev': 'tun0'}], []), + ([], [{'ifname': 'wlan0', 'addr_info': [{'family': 'inet', 'local': '10.203.0.77', 'prefixlen': 24}]}]), + ([{'dst': '10.203.0.8/32', 'dev': 'eth0'}], [])]: + with self.subTest(routes=routes, addresses=addresses), self.assertRaises(image.ImageError): + image.check_network_conflicts(routes, addresses) + + def test_existing_owned_bridge_route_allowed(self): + image.check_network_conflicts([{'dst': '10.203.0.0/24', 'dev': 'hydrabr0'}], + [{'ifname': 'hydrabr0', 'addr_info': [{'family': 'inet', 'local': '10.203.0.1', 'prefixlen': 24}]}]) + + def test_malformed_data_not_silently_accepted(self): + for routes, addresses in [('bad', []), ([{'dst': 'nonsense', 'dev': 'eth0'}], []), + ([], [{'ifname': 'eth0', 'addr_info': [{'family': 'inet', 'local': 'bad', 'prefixlen': 24}]}])]: + with self.subTest(routes=routes), self.assertRaises(image.ImageError): + image.check_network_conflicts(routes, addresses) + + def test_network_conflict_stops_before_any_mutation(self): + calls = [] + def run(argv, **kwargs): + calls.append(argv) + if argv == ['ip', '-json', '-4', 'route', 'show', 'table', 'all']: + return subprocess.CompletedProcess(argv, 0, json.dumps([{'dst': '10.203.0.0/24', 'dev': 'eth0'}]), '') + if argv == ['ip', '-json', '-4', 'address', 'show']: + return subprocess.CompletedProcess(argv, 0, '[]', '') + self.fail('Unexpected operation before conflict validation: ' + repr(argv)) + with self.assertRaises(image.ImageError): + image.configure_network(run) + self.assertEqual(len(calls), 2) + + def test_manual_bridge_has_nat_without_forward_filter(self): + changes = [] + def run(argv, **kwargs): + responses = { + ('ip', '-json', '-4', 'route', 'show', 'table', 'all'): '[]', + ('ip', '-json', '-4', 'address', 'show'): '[]', + ('ip', '-json', '-details', 'link', 'show'): '[]', + } + if tuple(argv) in responses: + return subprocess.CompletedProcess(argv, 0, responses[tuple(argv)], '') + if argv[0] == 'nmcli' and argv[1] == '--get-values': + return subprocess.CompletedProcess(argv, 10, '', 'not found') + if argv == ['nft', 'list', 'table', 'ip', 'hydra_nat']: + return subprocess.CompletedProcess(argv, 1, '', 'not found') + changes.append((argv, kwargs)) + return subprocess.CompletedProcess(argv, 0, '', '') + image.configure_network(run) + addition = next(argv for argv, kwargs in changes if argv[:3] == ['nmcli', 'connection', 'add']) + self.assertEqual(addition[addition.index('ipv4.method') + 1], 'manual') + rules = next(kwargs['input'] for argv, kwargs in changes if argv == ['nft', '-f', '-']) + self.assertIn('masquerade', rules) + self.assertNotIn('hook forward', rules) + self.assertNotIn('reject', rules) + self.assertNotIn('flush ruleset', rules) + + def test_foreign_bridge_name_refused(self): + calls = [] + def run(argv, **kwargs): + calls.append(argv) + if argv[0] == 'ip': + output = '[{"ifname": "hydrabr0", "linkinfo": {"info_kind": "bridge"}}]' if 'link' in argv else '[]' + return subprocess.CompletedProcess(argv, 0, output, '') + if argv[0] == 'nmcli' and argv[1] == '--get-values': + return subprocess.CompletedProcess(argv, 10, '', 'not found') + self.fail('Foreign interface triggered a network mutation: ' + repr(argv)) + with self.assertRaises(image.ImageError): + image.configure_network(run) + + def test_changed_owned_connection_refused(self): + def run(argv, **kwargs): + if argv[0] == 'ip': + return subprocess.CompletedProcess(argv, 0, '[]', '') + if argv[0] == 'nmcli' and argv[1] == '--get-values': + return subprocess.CompletedProcess(argv, 0, image.CONNECTION_UUID + '\nbridge\nhydrabr0\nshared\n10.203.0.1/24\n', '') + self.fail('Changed connection triggered a network mutation: ' + repr(argv)) + with self.assertRaises(image.ImageError): + image.configure_network(run) + + +if __name__ == '__main__': + unittest.main()