feat: assemble an installable Gentoo live image in an exported rootfs
This commit is contained in:
+135
-9
@@ -9,6 +9,7 @@ import os
|
||||
from pathlib import Path
|
||||
import re
|
||||
import shutil
|
||||
import shlex
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
@@ -190,10 +191,11 @@ if path.exists():
|
||||
|
||||
def input_id(self):
|
||||
digest = hashlib.sha256()
|
||||
for path in sorted((self.source_dir / 'image').rglob('*')):
|
||||
if path.is_file():
|
||||
digest.update(str(path.relative_to(self.source_dir)).encode())
|
||||
digest.update(path.read_bytes())
|
||||
for directory in ('image', 'patches'):
|
||||
for path in sorted((self.source_dir / directory).rglob('*')):
|
||||
if path.is_file():
|
||||
digest.update(str(path.relative_to(self.source_dir)).encode())
|
||||
digest.update(path.read_bytes())
|
||||
return digest.hexdigest()
|
||||
|
||||
def record(self, step, **details):
|
||||
@@ -209,6 +211,13 @@ if path.exists():
|
||||
entry = self.manifest().get(step, {})
|
||||
if not entry.get('complete') or entry.get('inputs') != self.input_id():
|
||||
return False
|
||||
if step in ('rootfs', 'iso'):
|
||||
from . import image
|
||||
if entry.get('source_inputs') != image.source_identity(self.source_dir):
|
||||
return False
|
||||
if not image.artifacts_valid(self.work / 'artifacts', entry):
|
||||
return False
|
||||
return self.complete('packages' if step == 'rootfs' else 'rootfs')
|
||||
if step == 'packages':
|
||||
if not (self.work / 'artifacts/packages.json').is_file():
|
||||
return False
|
||||
@@ -295,6 +304,9 @@ if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then
|
||||
printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot
|
||||
fi
|
||||
cp -a /hydra-source/image/portage/. /etc/portage/
|
||||
mkdir -p /etc/portage/patches/app-emulation/qemu /etc/portage/patches/app-emulation/virt-viewer
|
||||
cp /hydra-source/patches/qemu-virtio-refresh-rate.patch /etc/portage/patches/app-emulation/qemu/10-hydra-refresh.patch
|
||||
cp /hydra-source/patches/virt-viewer-hydra-session.patch /etc/portage/patches/app-emulation/virt-viewer/10-hydra-session.patch
|
||||
if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then
|
||||
mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted
|
||||
fi
|
||||
@@ -311,19 +323,135 @@ chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg
|
||||
atoms = [line.strip() for line in (self.source_dir / 'image/packages').read_text().splitlines()
|
||||
if line.strip() and not line.startswith('#')]
|
||||
import shlex
|
||||
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100']
|
||||
command = ['emerge', '--verbose', '--update', '--newuse', '--deep', '--backtrack=100',
|
||||
'--usepkg-exclude=app-emulation/qemu app-emulation/virt-viewer']
|
||||
if pretend:
|
||||
self.exec(shlex.join([*command, '--pretend', *atoms]))
|
||||
return
|
||||
inputs = self.input_id()
|
||||
self.record('packages', complete=False, job_log='/var/lib/hydra-build/packages.log')
|
||||
script = package_job_script([*command, *atoms], inputs=inputs)
|
||||
patches = hashlib.sha256()
|
||||
for name in ('qemu-virtio-refresh-rate.patch', 'virt-viewer-hydra-session.patch'):
|
||||
patches.update((self.source_dir / 'patches' / name).read_bytes())
|
||||
patch_id = patches.hexdigest()
|
||||
forced = ['emerge', '--verbose', '--oneshot', '--usepkg=n', '--getbinpkg=n',
|
||||
'app-emulation/qemu', 'app-emulation/virt-viewer']
|
||||
package_commands = shlex.join([*command, *atoms]) + '\n' + f'''if [ "$(cat /var/lib/hydra-build/patched-source.sha256 2>/dev/null || true)" != {shlex.quote(patch_id)} ]; then
|
||||
{shlex.join(forced)}
|
||||
printf '%s\\n' {shlex.quote(patch_id)} > /var/lib/hydra-build/patched-source.sha256
|
||||
fi'''
|
||||
script = package_job_script(['/bin/bash', '-euc', package_commands], inputs=inputs)
|
||||
self.exec(shlex.join(['python3', '-c', script]))
|
||||
if self.input_id() != inputs:
|
||||
raise BuildError('Image inputs changed during package installation; completion was not recorded')
|
||||
inventory = self.exec('cat /var/lib/hydra-build/packages.json', capture=True).stdout
|
||||
(self.work / 'artifacts/packages.json').write_text(inventory)
|
||||
self.record('packages', inventory='artifacts/packages.json')
|
||||
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id)
|
||||
|
||||
def assembly(self, snapshot_digest=None):
|
||||
"""A separate rootless container processes an exported copy, never the builder."""
|
||||
if snapshot_digest is None:
|
||||
state = self.manifest().get('rootfs', {})
|
||||
snapshot_digest = state.get('snapshot_sha256')
|
||||
if not isinstance(snapshot_digest, str) or not re.fullmatch('[a-f0-9]{64}', snapshot_digest):
|
||||
raise BuildError('Verified exported snapshot is required')
|
||||
name = self.name + '-image-' + snapshot_digest[:12]
|
||||
image = 'localhost/hydra-image:' + snapshot_digest
|
||||
exists = self.pod('container', 'exists', name, capture=True, check=False).returncode == 0
|
||||
if not exists:
|
||||
self.pod('import', str(self.work / 'artifacts/builder-rootfs.tar'), image)
|
||||
self.pod('run', '--detach', '--name', name, '--hostname', 'hydra-image-assembler',
|
||||
'--network', 'none', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined',
|
||||
'--volume', f'{self.source_dir}:/hydra-source:ro',
|
||||
'--volume', f'{self.work}:/hydra-output:rw', image, '/bin/sleep', 'infinity')
|
||||
container = self.inspect('container', name)
|
||||
for destination, source, writable in (('/hydra-source', self.source_dir, False),
|
||||
('/hydra-output', self.work, True)):
|
||||
mounts = [mount for mount in container.get('Mounts', []) if mount.get('Destination') == destination]
|
||||
if len(mounts) != 1 or mounts[0].get('Source') != str(source) or mounts[0].get('RW') is not writable:
|
||||
raise BuildError('Assembly mount provenance differs; refusing to write')
|
||||
provenance = self.inspect('image', container['Image'])
|
||||
if provenance.get('RootFS', {}).get('Layers') != ['sha256:' + snapshot_digest]:
|
||||
raise BuildError('Assembly image does not match the exported builder snapshot')
|
||||
if exists:
|
||||
self.pod('start', name)
|
||||
return name
|
||||
|
||||
def rootfs(self):
|
||||
from . import image
|
||||
if self.active_package_job():
|
||||
raise BuildError('A contained package job is running; wait before export')
|
||||
if not self.complete('packages'):
|
||||
raise BuildError('Verified completed packages are required before rootfs export')
|
||||
if self.complete('rootfs'):
|
||||
return
|
||||
source_inputs = image.source_identity(self.source_dir)
|
||||
self.record('rootfs', complete=False)
|
||||
original = self.work / 'artifacts/builder-rootfs.tar'
|
||||
self.pod('export', '--output', str(original), self.name)
|
||||
snapshot = image.digest(original)
|
||||
assembler = self.assembly(snapshot)
|
||||
commit = self.run(['git', '-C', str(self.source_dir), 'rev-parse', 'HEAD'], capture=True).stdout.strip()
|
||||
script = r"""root=/hydra-output/rootfs
|
||||
if [ -e "$root" ] || [ -L "$root" ]; then
|
||||
[ ! -L "$root" ] && [ -f "$root/.hydra-export-owned" ] || { echo 'Unowned rootfs output' >&2; exit 1; }
|
||||
rm -rf -- "$root"
|
||||
fi
|
||||
mkdir -p "$root"
|
||||
touch "$root/.hydra-export-owned"
|
||||
tar --extract --preserve-permissions --numeric-owner --file /hydra-output/artifacts/builder-rootfs.tar --directory "$root"
|
||||
/hydra-source/image/configure-rootfs.sh "$root"
|
||||
cp "$root/boot/vmlinuz-$(python3 /hydra-source/hydra/image.py kernel "$root")" /hydra-output/artifacts/vmlinuz
|
||||
cp "$root/boot/initramfs-live.img" /hydra-output/artifacts/initramfs-live.img
|
||||
cp "$root/usr/share/hydra/build-manifest.json" /hydra-output/artifacts/rootfs-manifest.json
|
||||
# Keep ownership for safe retries outside the shipped payload.
|
||||
mksquashfs "$root" /hydra-output/artifacts/squashfs.img -noappend -comp zstd -processors 4 -e boot/initramfs-live.img .hydra-export-owned
|
||||
chmod 0644 /hydra-output/artifacts/vmlinuz /hydra-output/artifacts/initramfs-live.img /hydra-output/artifacts/squashfs.img /hydra-output/artifacts/rootfs-manifest.json
|
||||
"""
|
||||
self.pod('exec', '--env', f'HYDRA_SOURCE_COMMIT={commit}', assembler, '/bin/bash', '-euc', script)
|
||||
if image.source_identity(self.source_dir) != source_inputs:
|
||||
raise BuildError('Source inputs changed during rootfs assembly; completion not recorded')
|
||||
manifest = json.loads((self.work / 'artifacts/rootfs-manifest.json').read_text())
|
||||
if not manifest.get('packages') or manifest.get('source_files') != image.source_files(self.source_dir):
|
||||
raise BuildError('Payload inventory/source manifest is incomplete or stale')
|
||||
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
||||
('vmlinuz', 'initramfs-live.img', 'squashfs.img', 'rootfs-manifest.json')}
|
||||
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
|
||||
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
|
||||
|
||||
def iso(self):
|
||||
from . import image
|
||||
if not self.complete('rootfs'):
|
||||
raise BuildError('Verified rootfs artifacts are required before ISO assembly')
|
||||
if self.complete('iso'):
|
||||
return
|
||||
source_inputs = image.source_identity(self.source_dir)
|
||||
assembler = self.assembly()
|
||||
self.record('iso', complete=False)
|
||||
script = r"""tree=/hydra-output/iso-tree
|
||||
[ ! -L "$tree" ] || { echo 'ISO output cannot be a symlink' >&2; exit 1; }
|
||||
mkdir -p "$tree/boot/grub" "$tree/LiveOS"
|
||||
cp /hydra-output/artifacts/vmlinuz "$tree/boot/vmlinuz"
|
||||
cp /hydra-output/artifacts/initramfs-live.img "$tree/boot/initramfs-live.img"
|
||||
cp /hydra-output/artifacts/squashfs.img "$tree/LiveOS/squashfs.img"
|
||||
cp /hydra-source/image/grub.cfg "$tree/boot/grub/grub.cfg"
|
||||
cp /hydra-output/artifacts/rootfs-manifest.json "$tree/build-manifest.json"
|
||||
cp /hydra-output/artifacts/packages.json "$tree/packages.json"
|
||||
grub-mkrescue --directory=/usr/lib/grub/x86_64-efi --output=/hydra-output/artifacts/hydra-os.iso "$tree" -- -volid HYDRA_OS
|
||||
xorriso -indev /hydra-output/artifacts/hydra-os.iso -report_el_torito plain -pvd_info > /hydra-output/artifacts/iso-inspection.txt 2>&1
|
||||
chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-inspection.txt
|
||||
"""
|
||||
self.pod('exec', assembler, '/bin/bash', '-euc', script)
|
||||
inspection = (self.work / 'artifacts/iso-inspection.txt').read_text()
|
||||
if 'HYDRA_OS' not in inspection or not re.search(r'El Torito boot img.*UEFI', inspection):
|
||||
raise BuildError('ISO does not report the expected volume label and UEFI boot image')
|
||||
if image.source_identity(self.source_dir) != source_inputs:
|
||||
raise BuildError('Source inputs changed during ISO assembly; completion not recorded')
|
||||
checksum = image.digest(self.work / 'artifacts/hydra-os.iso')
|
||||
(self.work / 'artifacts/hydra-os.iso.sha256').write_text(checksum + ' hydra-os.iso\n')
|
||||
artifacts = {name: image.digest(self.work / 'artifacts' / name) for name in
|
||||
('hydra-os.iso', 'hydra-os.iso.sha256', 'iso-inspection.txt')}
|
||||
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
|
||||
|
||||
def status(self):
|
||||
data = self.manifest()
|
||||
@@ -380,8 +508,6 @@ def main(argv=None):
|
||||
builder.status()
|
||||
return 0
|
||||
with builder.locked():
|
||||
if options.step in ('rootfs', 'iso'):
|
||||
raise BuildError(f'{options.step} assembly is not configured yet')
|
||||
if options.step == 'packages':
|
||||
builder.packages(options.pretend)
|
||||
else:
|
||||
|
||||
+296
@@ -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())
|
||||
Executable
+33
@@ -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"
|
||||
@@ -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"
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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=""
|
||||
|
||||
@@ -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 "
|
||||
@@ -0,0 +1,5 @@
|
||||
[terminal]
|
||||
vt = 1
|
||||
[default_session]
|
||||
command = "/usr/bin/agreety --cmd /usr/bin/hydra-session"
|
||||
user = "greetd"
|
||||
@@ -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"
|
||||
@@ -0,0 +1,5 @@
|
||||
network = "bridge"
|
||||
bridge = "hydrabr0"
|
||||
width = 1920
|
||||
height = 1080
|
||||
refresh_millihz = 144000
|
||||
@@ -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
|
||||
@@ -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"
|
||||
]
|
||||
@@ -0,0 +1 @@
|
||||
LANG=en_US.UTF-8
|
||||
@@ -0,0 +1,2 @@
|
||||
en_US.UTF-8 UTF-8
|
||||
ru_RU.UTF-8 UTF-8
|
||||
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
allow hydrabr0
|
||||
@@ -0,0 +1 @@
|
||||
%wheel ALL=(ALL:ALL) ALL
|
||||
@@ -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
@@ -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"
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user