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())
+33
View File
@@ -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"
+6
View File
@@ -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"
+12
View File
@@ -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
}
+1
View File
@@ -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
+1 -1
View File
@@ -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=""
+3
View File
@@ -0,0 +1,3 @@
GRUB_DISTRIBUTOR="HydraOS"
GRUB_TIMEOUT=5
GRUB_CMDLINE_LINUX_DEFAULT="console=tty0 console=ttyS0,115200n8"
@@ -0,0 +1,3 @@
hostonly="no"
hostonly_cmdline="no"
omit_dracutmodules+=" dmsquash-live "
+5
View File
@@ -0,0 +1,5 @@
[terminal]
vt = 1
[default_session]
command = "/usr/bin/agreety --cmd /usr/bin/hydra-session"
user = "greetd"
+8
View File
@@ -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"
+5
View File
@@ -0,0 +1,5 @@
network = "bridge"
bridge = "hydrabr0"
width = 1920
height = 1080
refresh_millihz = 144000
+11
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
HydraOS live medium: changes to the host root are not persistent.
@@ -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"
]
+1
View File
@@ -0,0 +1 @@
LANG=en_US.UTF-8
+2
View File
@@ -0,0 +1,2 @@
en_US.UTF-8 UTF-8
ru_RU.UTF-8 UTF-8
+5
View File
@@ -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.
+6
View File
@@ -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
}
+1
View File
@@ -0,0 +1 @@
allow hydrabr0
@@ -0,0 +1 @@
%wheel ALL=(ALL:ALL) ALL
+1
View File
@@ -0,0 +1 @@
hydra-live ALL=(ALL:ALL) NOPASSWD: ALL
@@ -0,0 +1 @@
net.ipv4.ip_forward = 1
@@ -0,0 +1,3 @@
[Login]
KillUserProcesses=no
UserStopDelaySec=infinity
@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin hydra-live --noclear %I $TERM
@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=/usr/bin/greetd --config /etc/greetd/live.toml
@@ -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
@@ -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
@@ -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
@@ -0,0 +1,3 @@
[Service]
ExecStart=
ExecStart=-/sbin/agetty --autologin hydra-live --noclear %I $TERM
+7
View File
@@ -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"
+278
View File
@@ -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()