Files

733 lines
41 KiB
Python

import importlib.util
import bz2
import json
import os
import shlex
import sys
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()
self.vendor_tmpfiles = {
'home.conf': '# vendor home policy\nQ /home 0755 - - -\nq /srv 0755 - - -\n',
'portables.conf': '# vendor portables policy\nQ /var/lib/portables 0700\n',
'systemd-nspawn.conf': (
'# vendor nspawn policy\nQ /var/lib/machines 0700 - - -\n'
'R! /var/lib/machines/.#*\nR! /.#machine.*\n'
'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n'
'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'),
}
for name, content in self.vendor_tmpfiles.items():
self.file('usr/lib/tmpfiles.d/' + name, content)
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_managed_root_tmpfiles_creates_ordinary_directories_without_losing_vendor_rules(self):
self.kernel()
source = Path(__file__).resolve().parents[1]
retained = [self.file(path + '/retained-data', 'user data')
for path in ('srv', 'var/lib/portables', 'var/lib/machines')]
image.configure_rootfs(self.root, source)
expected = {
'home.conf': '# vendor home policy\nQ /home 0755 - - -\nd /srv 0755 - - -\n',
'portables.conf': '# vendor portables policy\nd /var/lib/portables 0700\n',
'systemd-nspawn.conf': (
'# vendor nspawn policy\nd /var/lib/machines 0700 - - -\n'
'R! /var/lib/machines/.#*\nR! /.#machine.*\n'
'L? /run/systemd/io.systemd.NamespaceResource - - - - /run/host/io.systemd.NamespaceResource\n'
'L? /run/systemd/io.systemd.MountFileSystem - - - - /run/host/io.systemd.MountFileSystem\n'),
}
for name, content in expected.items():
with self.subTest(name=name):
override = self.root / 'etc/tmpfiles.d' / name
self.assertTrue(override.is_file(), f'missing managed-root policy: {name}')
self.assertEqual(override.read_text(), content)
self.assertEqual(override.stat().st_mode & 0o777, 0o644)
self.assertEqual((self.root / 'usr/lib/tmpfiles.d' / name).read_text(),
self.vendor_tmpfiles[name])
self.assertEqual([path.read_text() for path in retained], ['user data'] * 3)
def test_managed_root_tmpfiles_refuses_missing_duplicate_or_changed_vendor_rule(self):
import shutil
self.kernel()
source = Path(__file__).resolve().parents[1]
for index, content in enumerate((None, '# no managed path\n', 'd /var/lib/machines 0700\n',
'Q /var/lib/machines 0700\nq /var/lib/machines 0700\n',
'Q /var/lib/machines 0700\nd /var/lib/machines 0700\n')):
with self.subTest(content=content):
root = Path(self.tmp.name) / ('drift-' + str(index))
shutil.copytree(self.root, root)
vendor = root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf'
if content is None:
vendor.unlink()
else:
vendor.write_text(content)
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
image.configure_rootfs(root, source)
self.assertFalse((root / 'etc/tmpfiles.d/home.conf').exists())
def test_managed_root_tmpfiles_refuses_existing_override(self):
self.kernel()
original = self.file('etc/tmpfiles.d/home.conf', 'd /srv 0710 local local -\n')
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
self.assertEqual(original.read_text(), 'd /srv 0710 local local -\n')
def test_managed_root_tmpfiles_refuses_vendor_symlink(self):
self.kernel()
original = self.file('usr/lib/tmpfiles.d/other.conf', 'Q /var/lib/machines 0700\n')
vendor = self.root / 'usr/lib/tmpfiles.d/systemd-nspawn.conf'
vendor.unlink()
vendor.symlink_to('other.conf')
with self.assertRaisesRegex(image.ImageError, 'tmpfiles'):
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
self.assertEqual(original.read_text(), 'Q /var/lib/machines 0700\n')
def test_installs_independent_updater_and_base_recovery_assets(self):
self.kernel()
source = Path(__file__).resolve().parents[1]
image.configure_rootfs(self.root, source)
for relative, asset, mode in (
('usr/lib/hydra-update/engine.py', 'hydra/update_engine.py', 0o644),
('usr/bin/hydra-update', 'bin/hydra-update', 0o755),
('usr/bin/hydra-update-recover', 'bin/hydra-update-recover', 0o755),
('usr/lib/hydra/bin/hydra-system', 'bin/hydra-system', 0o755),
('etc/hydra/update.pub', 'image/rootfs/etc/hydra/update.pub', 0o644),
):
with self.subTest(relative=relative):
path = self.root / relative
self.assertTrue(path.is_file(), relative)
self.assertEqual(path.read_bytes(), (source / asset).read_bytes())
self.assertEqual(path.stat().st_mode & 0o777, mode)
wrapper = self.root / 'usr/bin/hydra-system'
self.assertTrue(wrapper.is_file(), 'base recovery launcher is missing')
self.assertEqual(wrapper.stat().st_mode & 0o777, 0o755)
self.assertEqual(wrapper.read_text(),
'#!/usr/bin/python3 -I\nimport sys\nsys.path.insert(0, "/usr/lib/hydra")\n'
'from hydra.system_recovery import main\nraise SystemExit(main())\n')
self.assertEqual((self.root / 'usr/lib/hydra/hydra/doctor.py').read_bytes(),
(source / 'hydra/doctor.py').read_bytes())
def test_root_assembly_sets_trusted_asset_ownership(self):
from unittest.mock import patch
self.kernel()
with patch('hydra.image.os.geteuid', return_value=0), patch('hydra.image.os.chown') as chown:
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
for relative in ('usr/lib/hydra-update/engine.py', 'usr/lib/hydra-update',
'usr/bin/hydra-update', 'usr/bin/hydra-update-recover', 'etc/hydra/update.pub'):
chown.assert_any_call(self.root / relative, 0, 0)
def test_missing_trusted_asset_refuses_configuration_before_mutation(self):
import shutil
source = Path(__file__).resolve().parents[1]
fixture = Path(self.tmp.name) / 'source'
fixture.mkdir()
for directory in ('bin', 'hydra', 'image'):
shutil.copytree(source / directory, fixture / directory,
ignore=shutil.ignore_patterns('__pycache__'))
for asset in ('bin/hydra-update', 'bin/hydra-update-recover', 'hydra/update_engine.py',
'bin/hydra-system', 'hydra/system_recovery.py', 'image/rootfs/etc/hydra/update.pub'):
with self.subTest(asset=asset):
self.root = Path(self.tmp.name) / ('root-' + asset.replace('/', '-'))
self.root.mkdir()
self.kernel('6.18.54-gentoo-dist-bin')
original = self.file('etc/machine-id', 'preserved-builder-id')
missing = fixture / asset
saved = missing.read_bytes()
missing.unlink()
try:
with self.assertRaisesRegex(image.ImageError, 'missing'):
image.configure_rootfs(self.root, fixture)
self.assertEqual(original.read_text(), 'preserved-builder-id')
self.assertFalse((self.root / 'usr').exists())
finally:
missing.write_bytes(saved)
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_real_gentoo_kernel_bin_layout(self):
version = '6.18.54-gentoo-dist-bin'
(self.root / 'lib/modules' / version).mkdir(parents=True)
kernel = self.file('boot/kernel-' + version, 'real Gentoo kernel')
self.assertEqual(image.kernel_version(self.root), version)
self.assertEqual(image.kernel_image(self.root), kernel)
def test_configured_real_kernel_layout_is_accepted_by_installer(self):
from hydra.installer import select_kernel
version = '6.18.54-gentoo-dist-bin'
(self.root / 'lib/modules' / version).mkdir(parents=True)
kernel = self.file('boot/kernel-' + version, 'real Gentoo kernel')
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
self.assertEqual(select_kernel(self.root, None), version)
alias = self.root / ('boot/vmlinuz-' + version)
self.assertTrue(alias.samefile(kernel))
self.assertEqual(image.kernel_image(self.root), alias)
def test_exported_repository_sync_works_with_extracted_tree(self):
import configparser
self.kernel()
self.file('etc/portage/repos.conf/gentoo.conf',
'[gentoo]\nlocation=/var/db/repos/gentoo\nsync-type=git\nauto-sync=no\n')
self.file('var/db/repos/gentoo/profiles/repo_name', 'gentoo')
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
config = configparser.ConfigParser()
config.read(self.root / 'etc/portage/repos.conf/gentoo.conf')
repo = config['gentoo']
self.assertEqual(repo['sync-type'], 'rsync')
self.assertEqual(repo['sync-uri'], 'rsync://rsync.gentoo.org/gentoo-portage')
self.assertEqual(repo['auto-sync'], 'yes')
self.assertEqual(repo['sync-rsync-verify-metamanifest'], 'yes')
self.assertEqual(repo['sync-openpgp-key-path'], '/usr/share/openpgp-keys/gentoo-release.asc')
self.assertFalse((self.root / 'var/db/repos/gentoo/.git').exists())
source = Path(__file__).resolve().parents[1]
builder_config = (source / 'image/portage/repos.conf/gentoo.conf').read_text()
self.assertIn('auto-sync = no', builder_config)
def test_conflicting_kernel_images_refused(self):
version = self.kernel()
self.file('boot/kernel-' + version, 'different kernel')
with self.assertRaises(image.ImageError):
image.kernel_version(self.root)
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_mount_faults_clean_only_successful_bindings(self):
source = Path(__file__).resolve().parents[1]
tools = Path(self.tmp.name) / 'tools'
tools.mkdir()
for name in ('python3', 'chroot', 'ln', 'systemctl'):
script = tools / name
script.write_text('#!/bin/sh\nexit 0\n')
script.chmod(0o755)
mount = tools / 'mount'
mount.write_text('#!/bin/sh\nn=$(cat "$STATE/count" 2>/dev/null || echo 0)\n'
'n=$((n+1)); echo "$n" > "$STATE/count"\n'
'test "$n" != "$FAIL_MOUNT"\n')
mount.chmod(0o755)
unmount = tools / 'umount'
unmount.write_text('#!/bin/sh\nprintf "%s\\n" "$@" >> "$STATE/unmounts"\n'
'test "$FAIL_UNMOUNT" != 1\n')
unmount.chmod(0o755)
for failed, cleanup_failed in ((1, 0), (2, 0), (3, 0), (0, 0), (0, 1)):
with self.subTest(failed=failed, cleanup_failed=cleanup_failed):
state = Path(self.tmp.name) / f'state-{failed}-{cleanup_failed}'
state.mkdir()
env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'],
STATE=str(state), FAIL_MOUNT=str(failed), FAIL_UNMOUNT=str(cleanup_failed))
result = subprocess.run(['bash', str(source / 'image/configure-rootfs.sh'), '/hydra-output/rootfs'],
env=env, capture_output=True, text=True)
expected = ['/hydra-output/rootfs/dev', '/hydra-output/rootfs/proc', '/hydra-output/rootfs/sys']
expected = list(reversed(expected[:failed - 1 if failed else 3]))
actual = (state / 'unmounts').read_text().splitlines() if (state / 'unmounts').exists() else []
self.assertEqual(actual, expected)
self.assertEqual(result.returncode == 0, not failed and not cleanup_failed)
def test_retry_guard_uses_valid_real_findmnt_options(self):
from hydra.build import rootfs_retry_guard_script
self.file('.hydra-export-owned')
script = 'set -eu\nroot=' + shlex.quote(str(self.root)) + '\n' + rootfs_retry_guard_script()
result = subprocess.run(['bash', '-c', script], capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.root.exists())
def test_retry_refuses_removing_owned_rootfs_with_stale_child_mount(self):
from hydra.build import rootfs_retry_guard_script
marker = self.file('.hydra-export-owned')
preserved = self.file('dev/guest-data')
tools = Path(self.tmp.name) / 'guard-tools'
tools.mkdir()
findmnt = tools / 'findmnt'
findmnt.write_text('#!/bin/sh\nprintf "%s\\n" / "$STALE_MOUNT"\n')
findmnt.chmod(0o755)
env = dict(os.environ, PATH=str(tools) + ':' + os.environ['PATH'], STALE_MOUNT=str(self.root / 'dev'))
script = 'set -eu\nroot=' + shlex.quote(str(self.root)) + '\n' + rootfs_retry_guard_script()
result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True)
self.assertNotEqual(result.returncode, 0)
self.assertTrue(marker.is_file())
self.assertTrue(preserved.is_file())
self.assertIn('mounted', result.stderr)
env['STALE_MOUNT'] = str(Path(self.tmp.name) / 'different-rootfs')
result = subprocess.run(['bash', '-c', script], env=env, capture_output=True, text=True)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertFalse(self.root.exists())
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_export_filters_saved_environment_proxies_but_retains_package_metadata(self):
self.assertTrue(callable(getattr(image, 'sanitize_saved_environments', None)), 'saved environment scrubber is missing')
# Portable stand-in for the external Portage filter API, not the scrubber.
helper = self.file('usr/lib/portage/python3.14/filter-bash-environment.py',
"import re\ndef filter_bash_environment(pattern, source, output):\n"
" for line in source:\n"
" match = re.match(rb'declare -x ([A-Za-z_][A-Za-z0-9_]*)=', line)\n"
" if not match or not pattern.fullmatch(match.group(1)):\n"
" output.write(line)\n")
path = self.root / 'var/db/pkg/app-emulation/qemu-9.2.4/environment.bz2'
path.parent.mkdir(parents=True)
package_metadata = b'declare -x LICENSE="GPL-2"\ndeclare -x EAPI="8"\n'
path.write_bytes(bz2.compress(package_metadata + b'declare -x HTTPS_PROXY="https://user:secret@builder.invalid"\n'
b'declare -x HTTP_PROXY="http://127.0.0.1:1234"\n'
b'declare -x NO_PROXY="localhost"\n'))
result = image.sanitize_saved_environments(self.root)
self.assertEqual(bz2.decompress(path.read_bytes()), package_metadata)
self.assertEqual(result['filtered_files'], 1)
self.assertEqual(result['variable_names'], ['HTTPS_PROXY', 'HTTP_PROXY', 'NO_PROXY'])
def test_saved_environment_filter_refuses_missing_portage_helper(self):
self.assertTrue(callable(getattr(image, 'sanitize_saved_environments', None)), 'saved environment scrubber is missing')
path = self.root / 'var/db/pkg/app-emulation/qemu-9.2.4/environment.bz2'
path.parent.mkdir(parents=True)
original = bz2.compress(b'declare -x HTTPS_PROXY="private"\n')
path.write_bytes(original)
with self.assertRaises(image.ImageError):
image.sanitize_saved_environments(self.root)
self.assertEqual(path.read_bytes(), original)
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_installed_helper_uses_relocated_payload_and_opens_empty_recovery(self):
self.kernel()
image.configure_rootfs(self.root, Path(__file__).resolve().parents[1])
terminal = Path(self.tmp.name) / 'terminal'
terminal.write_text('#!/bin/sh\nshift\nexec "$@"\n')
terminal.chmod(0o755)
manifest = Path(self.tmp.name) / 'session.json'
manifest.write_text(json.dumps({'config': str(Path(self.tmp.name) / 'config'),
'state': str(Path(self.tmp.name) / 'state'),
'runtime': str(Path(self.tmp.name) / 'runtime'),
'terminal': str(terminal), 'names': []}))
script = r"""import json, pathlib, subprocess, sys
sys.path.insert(0, sys.argv[1])
from hydra.session import _module_argv
helper = _module_argv('clients', pathlib.Path(sys.argv[2]))
help_result = subprocess.run(helper[:2] + ['--help'], text=True, capture_output=True)
print(help_result.stdout)
print(help_result.stderr, file=sys.stderr)
if help_result.returncode:
sys.exit(help_result.returncode)
result = subprocess.run(helper, input=':quit\n', text=True, capture_output=True, timeout=10)
print(result.stdout)
print(result.stderr, file=sys.stderr)
sys.exit(result.returncode)
"""
result = subprocess.run([sys.executable, '-I', '-c', script, str(self.root / 'usr/lib/hydra'), str(manifest)],
cwd=self.tmp.name, text=True, capture_output=True, timeout=20)
self.assertEqual(result.returncode, 0, result.stderr)
self.assertIn('Hydra OS', result.stdout)
self.assertIn('run', result.stdout)
def test_installed_updates_exclude_unadapted_qemu_and_viewer_binaries(self):
self.kernel()
source = Path(__file__).resolve().parents[1]
self.file('etc/portage/make.conf', (source / 'image/portage/make.conf').read_text())
image.configure_rootfs(self.root, source)
settings = subprocess.run(['bash', '-euc', 'source "$1"; printf "%s" "$EMERGE_DEFAULT_OPTS"',
'--', str(self.root / 'etc/portage/make.conf')], text=True, capture_output=True, check=True)
defaults = shlex.split(settings.stdout)
exclusions = [item.split('=', 1)[1].split() for item in defaults if item.startswith('--usepkg-exclude=')]
self.assertEqual(exclusions, [['app-emulation/qemu', 'app-emulation/virt-viewer']])
self.assertIn('--getbinpkg', defaults)
def test_export_enables_pulse_socket_without_starting_user_services(self):
source = Path(__file__).resolve().parents[1]
self.assertTrue(callable(getattr(image, 'enable_user_audio', None)), 'user audio enable is missing')
units = self.root / 'usr/lib/systemd/user'
units.mkdir(parents=True)
for name, section in [('pipewire.socket', 'Socket'), ('pipewire-pulse.socket', 'Socket'),
('wireplumber.service', 'Service')]:
target = 'sockets.target' if section == 'Socket' else 'default.target'
(units / name).write_text(f'[{section}]\n[Install]\nWantedBy={target}\n')
image.enable_user_audio(self.root)
for target, name in [('sockets.target', 'pipewire.socket'), ('sockets.target', 'pipewire-pulse.socket'),
('default.target', 'wireplumber.service')]:
link = self.root / 'etc/systemd/user' / (target + '.wants') / name
self.assertTrue(link.is_symlink(), name)
self.assertEqual(os.readlink(link), '/usr/lib/systemd/user/' + name)
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 inventory(self, records=None):
records = records if records is not None else [{'package': 'app-emulation/qemu-9.2.4', 'use': ['spice'], 'build_time': '123'}]
self.builder.work.mkdir(exist_ok=True)
artifacts = self.builder.work / 'artifacts'
artifacts.mkdir(exist_ok=True)
path = artifacts / 'packages.json'
path.write_text(json.dumps(records))
original = self.builder.complete
self.builder.complete = lambda step: True if step == 'prepare' else original(step)
self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path))
return path
def test_changed_inventory_invalidates_package_checkpoint(self):
path = self.inventory()
self.assertTrue(self.builder.complete('packages'))
path.write_text('[{"package":"app-emulation/qemu-8.0", "use": [], "build_time": null}]')
self.assertFalse(self.builder.complete('packages'))
def test_iso_refuses_hfs_hybrid_that_shadows_live_volume_on_usb(self):
from hydra.build import BuildError
self.inventory()
self.builder.complete = lambda step: step == 'rootfs'
self.builder.assembly = lambda: 'isolated-assembler'
artifacts = self.builder.work / 'artifacts'
def pod(*args, **kwargs):
self.assertEqual(args[:2], ('exec', 'isolated-assembler'))
(artifacts / 'hydra-os.iso').write_bytes(b'ISO fixture')
(artifacts / 'iso-inspection.txt').write_text(
'Volume id : HYDRA_OS\n'
'El Torito boot img : 1 UEFI y none\n'
'System area summary: MBR protective-msdos-label GPT APM\n'
'GPT partname local : 3 HFSPLUS\n')
self.builder.pod = pod
with self.assertRaisesRegex(BuildError, 'HFS|APM'):
self.builder.iso()
self.assertFalse(self.builder.manifest()['iso']['complete'])
def test_malformed_inventory_with_matching_digest_is_refused(self):
for data in ['{', '{}', '[]', '[{"package":"wrong"}]',
'[{"package":"app-emulation/qemu-9.2.4", "use":"spice", "build_time":"123"}]',
'[{"package":"app-emulation/qemu-9.2.4", "use":[], "build_time":42}]']:
with self.subTest(data=data):
path = self.inventory()
path.write_text(data)
self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path))
self.assertFalse(self.builder.complete('packages'))
def test_changed_inventory_invalidates_completed_rootfs_and_iso(self):
path = self.inventory()
artifacts = path.parent
self.builder.active_package_job = lambda: False
self.builder.assembly = lambda snapshot=None: 'isolated-assembler'
self.builder.run = lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, 'commit\n', '')
def pod(*args, **kwargs):
if args[0] == 'export':
snapshot = artifacts / 'builder-rootfs.tar'
self.assertTrue(snapshot.is_file(), 'raw snapshot must be created privately before export')
self.assertEqual(snapshot.stat().st_mode & 0o777, 0o600)
snapshot.write_bytes(b'exported fixture')
elif args[0] == 'exec' and 'configure-rootfs.sh' in args[-1]:
for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'):
(artifacts / name).write_bytes(b'artifact')
(artifacts / 'rootfs-manifest.json').write_text(json.dumps({'kernel': '6.12.58', 'source_files': {},
'packages': [{'package': 'app-emulation/qemu-9.2.4', 'use': ['spice'], 'build_time': '123'}]}))
elif args[0] == 'exec' and 'grub-mkrescue' in args[-1]:
(artifacts / 'hydra-os.iso').write_bytes(b'ISO fixture')
(artifacts / 'iso-inspection.txt').write_text('HYDRA_OS\nEl Torito boot img : 1 UEFI\n')
else:
self.fail('Unexpected Podman operation: ' + repr(args))
self.builder.pod = pod
self.builder.rootfs()
self.builder.iso()
self.assertTrue(self.builder.complete('rootfs'))
self.assertTrue(self.builder.complete('iso'))
path.write_text('[{"package":"app-emulation/qemu-9.2.5", "use":[], "build_time":null}]')
self.builder.record('packages', inventory='artifacts/packages.json', inventory_sha256=image.digest(path))
self.assertTrue(self.builder.complete('packages'))
self.assertFalse(self.builder.complete('rootfs'))
self.assertFalse(self.builder.complete('iso'))
def test_export_inventory_must_match_validated_package_checkpoint(self):
from hydra.build import BuildError
path = self.inventory()
self.builder.complete = lambda step: step == 'packages'
self.builder.active_package_job = lambda: False
self.builder.assembly = lambda snapshot: 'isolated-assembler'
self.builder.run = lambda *args, **kwargs: subprocess.CompletedProcess(args, 0, 'commit\n', '')
def pod(*args, **kwargs):
artifacts = self.builder.work / 'artifacts'
if args[0] == 'export':
(artifacts / 'builder-rootfs.tar').write_bytes(b'exported fixture')
elif args[0] == 'exec':
for name in ('vmlinuz', 'initramfs-live.img', 'squashfs.img'):
(artifacts / name).write_bytes(b'artifact')
# Actual exported package differs from the checkpoint inventory.
(artifacts / 'rootfs-manifest.json').write_text(json.dumps({'kernel': '6.12.58', 'source_files': {},
'packages': [{'package': 'app-emulation/qemu-9.2.5', 'use': ['spice'], 'build_time': '456'}]}))
else:
self.fail('Unexpected Podman operation: ' + repr(args))
self.builder.pod = pod
with self.assertRaisesRegex(BuildError, 'inventory'):
self.builder.rootfs()
self.assertFalse(self.builder.manifest()['rootfs']['complete'])
def test_corrupt_local_package_archive_invalidates_fetch_checkpoint(self):
import hashlib
source = self.project / 'image'
source.mkdir()
(source / 'sources.json').write_text(json.dumps({
'stage3': {'url': 'https://example.test/stage.tar', 'sha256': hashlib.sha256(b'stage').hexdigest()},
'portage': {'url': 'https://example.test/repo.tar', 'sha256': hashlib.sha256(b'repo').hexdigest()},
'local_packages': [{'url': 'https://example.test/brightnessctl.tar.gz',
'filename': 'brightnessctl.tar.gz', 'sha256': hashlib.sha256(b'original-source').hexdigest()}]}))
with self.builder.locked():
(self.builder.cache / 'stage.tar').write_bytes(b'stage')
(self.builder.cache / 'repo.tar').write_bytes(b'repo')
archive = self.builder.cache / 'brightnessctl.tar.gz'
archive.write_bytes(b'original-source')
self.builder.record('fetch')
self.assertTrue(self.builder.complete('fetch'))
archive.write_bytes(b'corrupted-source')
self.assertFalse(self.builder.complete('fetch'))
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()