From 440fe42464bad66d2902cd5643614ac1ee01c6e4 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 04:12:00 +0300 Subject: [PATCH] Add verified rootless Gentoo image builder and package inputs --- hydra/build.py | 235 ++++++++++++++++++++ image/README.md | 38 ++++ image/packages | 30 +++ image/portage/binrepos.conf/gentoo.conf | 4 + image/portage/make.conf | 16 ++ image/portage/package.accept_keywords/hydra | 2 + image/portage/package.use/hydra | 8 + image/portage/repos.conf/gentoo.conf | 5 + image/sources.json | 17 ++ scripts/hydra-build | 9 + tests/test_build.py | 114 ++++++++++ 11 files changed, 478 insertions(+) create mode 100644 hydra/build.py create mode 100644 image/README.md create mode 100644 image/packages create mode 100644 image/portage/binrepos.conf/gentoo.conf create mode 100644 image/portage/make.conf create mode 100644 image/portage/package.accept_keywords/hydra create mode 100644 image/portage/package.use/hydra create mode 100644 image/portage/repos.conf/gentoo.conf create mode 100644 image/sources.json create mode 100755 scripts/hydra-build create mode 100644 tests/test_build.py diff --git a/hydra/build.py b/hydra/build.py new file mode 100644 index 0000000..7254f3a --- /dev/null +++ b/hydra/build.py @@ -0,0 +1,235 @@ +"""Verified, resumable Gentoo builds in a dedicated rootless Podman store.""" +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tempfile +import urllib.parse +import urllib.request + + +class BuildError(RuntimeError): + pass + + +def verify_hash(path, expected): + digest = hashlib.sha256() + with Path(path).open('rb') as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b''): + digest.update(chunk) + if digest.hexdigest() != expected: + raise BuildError(f'SHA256 mismatch: {path}') + + +def require_signature(status, fingerprint): + valid = [line.split() for line in status.splitlines() if line.startswith('[GNUPG:] VALIDSIG ')] + if not valid or not any(fields[-1].upper() == fingerprint.upper() for fields in valid): + raise BuildError('Required primary signature fingerprint is missing') + if any(token in status for token in ('BADSIG', 'ERRSIG', 'REVKEYSIG', 'EXPKEYSIG')): + raise BuildError('Invalid release signature') + + +class Builder: + def __init__(self, project, work, cache, podman=None, gpg=None, source_dir=None): + if os.geteuid() == 0: + raise BuildError('Run as the normal user, never host root') + self.project = Path(project).resolve() + self.source_dir = Path(source_dir or project).resolve() + self.work, self.cache = Path(work).resolve(), Path(cache).resolve() + for path in (self.work, self.cache): + if path == self.project or not path.is_relative_to(self.project): + raise BuildError('Work and cache paths must be inside the project, below its root') + if self.work == self.cache or self.work.is_relative_to(self.cache) or self.cache.is_relative_to(self.work): + raise BuildError('Work and cache must be separate directories') + self.podman = podman or shutil.which('podman') or 'podman' + self.gpg = gpg or shutil.which('gpg') or 'gpg' + runtime = Path(os.environ.get('XDG_RUNTIME_DIR', f'/run/user/{os.getuid()}')) + identifier = hashlib.sha256(str(self.work).encode()).hexdigest()[:12] + self.runroot = runtime / ('hydra-build-podman' if self.work == self.project / 'build' else f'hydra-build-{identifier}') + self.name = 'hydra-builder-' + identifier + self.image = 'localhost/hydra-stage3:20261011' + + def podman_argv(self, *args): + return [self.podman, '--root', str(self.work / 'podman'), '--runroot', str(self.runroot), '--storage-driver', 'overlay', *args] + + def container_run_args(self): + return ['run', '--detach', '--name', self.name, '--hostname', 'hydra-builder', + '--network', 'host', '--cap-add', 'SYS_ADMIN', '--security-opt', 'seccomp=unconfined', + '--volume', f'{self.source_dir}:/hydra-source:ro', '--volume', f'{self.cache}:/hydra-cache:ro', + self.image, '/bin/sleep', 'infinity'] + + def run(self, argv, capture=False, check=True): + print('+ ' + ' '.join(str(arg) for arg in argv), flush=True) + result = subprocess.run(argv, text=True, stdout=subprocess.PIPE if capture else None, + stderr=subprocess.PIPE if capture else None) + if check and result.returncode: + raise BuildError(f'Command failed ({result.returncode}): {argv[0]}\n{result.stderr or ""}') + return result + + def pod(self, *args, **kwargs): + return self.run(self.podman_argv(*args), **kwargs) + + def exec(self, command, capture=False): + return self.pod('exec', self.name, '/bin/bash', '-euc', command, capture=capture) + + def manifest(self): + path = self.work / 'manifest.json' + return json.loads(path.read_text()) if path.exists() else {} + + 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()) + return digest.hexdigest() + + def record(self, step, **details): + data = self.manifest() + data[step] = {'complete': True, 'inputs': self.input_id(), **details} + data['builder'] = self.name + path = self.work / 'manifest.json' + temp = path.with_suffix('.tmp') + temp.write_text(json.dumps(data, indent=2) + '\n') + temp.replace(path) + + def complete(self, step): + entry = self.manifest().get(step, {}) + if not entry.get('complete') or entry.get('inputs') != self.input_id(): + return False + if step == 'packages': + if not (self.work / 'artifacts/packages.json').is_file(): + return False + return self.pod('container', 'exists', self.name, capture=True, check=False).returncode == 0 + if step == 'prepare': + return self.pod('container', 'exists', self.name, capture=True, check=False).returncode == 0 + if step == 'fetch': + inputs = json.loads((self.source_dir / 'image/sources.json').read_text()) + try: + for name in ('stage3', 'portage'): + item = inputs[name] + path = self.cache / item.get('filename', Path(urllib.parse.urlparse(item['url']).path).name) + verify_hash(path, item['sha256']) + except (OSError, BuildError): + return False + return True + return False + + def download(self, url, filename=None): + parsed = urllib.parse.urlparse(url) + if parsed.scheme != 'https': + raise BuildError('Build input URLs must use HTTPS') + target = self.cache / (filename or Path(parsed.path).name) + if not target.exists(): + temp = target.with_suffix(target.suffix + '.partial') + print(f'Downloading {url}', flush=True) + with urllib.request.urlopen(url, timeout=120) as response, temp.open('wb') as stream: + shutil.copyfileobj(response, stream) + temp.replace(target) + return target + + def fetch(self): + inputs = json.loads((self.source_dir / 'image/sources.json').read_text()) + stage = inputs['stage3'] + archive = self.download(stage['url']) + verify_hash(archive, stage['sha256']) + snapshot = self.download(inputs['portage']['url'], inputs['portage']['filename']) + verify_hash(snapshot, inputs['portage']['sha256']) + signature = self.download(stage['signature_url']) + keyring = self.download(inputs['keyring_url']) + verify_hash(keyring, inputs['keyring_sha256']) + with tempfile.TemporaryDirectory(prefix='hydra-gpg-', dir=self.work) as home: + result = self.run([self.gpg, '--batch', '--homedir', home, '--no-default-keyring', '--keyring', str(keyring), + '--status-fd', '1', '--verify', str(signature), str(archive)], capture=True) + require_signature(result.stdout, stage['fingerprint']) + (self.work / 'artifacts/stage3-signature.txt').write_text(result.stdout + result.stderr) + self.record('fetch', sources=inputs) + return inputs + + def prepare(self): + inputs = self.fetch() # Reverify cached bytes even when resuming. + if self.pod('image', 'exists', self.image, capture=True, check=False).returncode: + self.pod('import', str(self.cache / Path(urllib.parse.urlparse(inputs['stage3']['url']).path).name), self.image) + if self.pod('container', 'exists', self.name, capture=True, check=False).returncode: + self.pod(*self.container_run_args()) + else: + self.pod('start', self.name) + snapshot = inputs['portage']['filename'] + # Snapshot extraction happens only inside the container; host paths are read-only. + self.exec(f'''mkdir -p /var/db/repos/gentoo +if [ ! -f /var/db/repos/gentoo/.hydra-snapshot ]; then + tar --extract --gzip --file /hydra-cache/{snapshot} --strip-components=1 --directory /var/db/repos/gentoo + printf '%s\\n' '{inputs['portage']['commit']}' > /var/db/repos/gentoo/.hydra-snapshot +fi +cp -a /hydra-source/image/portage/. /etc/portage/ +if [ -d /etc/portage/gnupg ] && [ ! -s /etc/portage/gnupg/mykeyid ]; then + mv /etc/portage/gnupg /etc/portage/gnupg.hydra-untrusted +fi +getuto +gpg --batch --homedir /etc/portage/gnupg --import /hydra-cache/service-keys.gpg +chmod a+r /etc/portage/gnupg/pubring.kbx /etc/portage/gnupg/trustdb.gpg +''') + self.record('prepare', image=self.image, snapshot=inputs['portage']['commit']) + + def packages(self, pretend=False): + self.prepare() + 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 ' + ('--pretend ' if pretend else '') + ' '.join(map(shlex.quote, atoms)) + self.exec(command) + if pretend: + return + inventory = self.exec('python3 - <<\'PY\'\nimport json,pathlib\nout=[]\nfor use in sorted(pathlib.Path("/var/db/pkg").glob("*/*/USE")):\n p=use.parent\n out.append({"package":str(p.relative_to("/var/db/pkg")),"use":use.read_text().split(),"build_time":(p/"BUILD_TIME").read_text().strip() if (p/"BUILD_TIME").exists() else None})\nprint(json.dumps(out,indent=2))\nPY', capture=True).stdout + (self.work / 'artifacts/packages.json').write_text(inventory) + self.record('packages', inventory='artifacts/packages.json') + + def status(self): + print(json.dumps(self.manifest(), indent=2)) + + @contextlib.contextmanager + def locked(self): + self.work.mkdir(parents=True, exist_ok=True) + self.cache.mkdir(parents=True, exist_ok=True) + (self.work / 'artifacts').mkdir(exist_ok=True) + with (self.work / '.hydra-build.lock').open('a') as lock: + try: + fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB) + except BlockingIOError as exc: + raise BuildError('Another Hydra build is running in this work directory') from exc + yield + + +def main(argv=None): + source = Path(__file__).resolve().parents[1] + # Linked worktrees share project-owned caches/build state in the primary checkout. + common = subprocess.run(['git', '-C', str(source), 'rev-parse', '--path-format=absolute', '--git-common-dir'], + text=True, capture_output=True) + project = Path(common.stdout.strip()).parent if common.returncode == 0 else source + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument('step', choices=('fetch', 'prepare', 'packages', 'rootfs', 'iso', 'status')) + parser.add_argument('--work', type=Path, default=project / 'build') + parser.add_argument('--cache', type=Path, default=project / 'cache') + parser.add_argument('--podman') + parser.add_argument('--gpg') + parser.add_argument('--pretend', action='store_true', help='resolve packages without installing') + options = parser.parse_args(argv) + try: + builder = Builder(project, options.work, options.cache, options.podman, options.gpg, source) + 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: + getattr(builder, options.step)() + return 0 + except (BuildError, OSError, ValueError) as exc: + print(f'hydra-build: {exc}', file=sys.stderr) + return 1 diff --git a/image/README.md b/image/README.md new file mode 100644 index 0000000..ea6fc9e --- /dev/null +++ b/image/README.md @@ -0,0 +1,38 @@ +HydraOS builds start from the pinned amd64 systemd stage3 and pinned Gentoo +repository in `sources.json`. Run the builder as the normal user with Python 3, +Podman and GnuPG available; temporary tool environments are sufficient. It +refuses host root and work/cache paths outside the primary project checkout. + +``` +scripts/hydra-build fetch +scripts/hydra-build prepare +scripts/hydra-build packages --pretend +scripts/hydra-build packages +scripts/hydra-build status +``` + +`--work PATH`, `--cache PATH`, `--podman PATH` and `--gpg PATH` override defaults. +Linked worktrees use the primary checkout's `build/` and `cache/`. The build +lock prevents simultaneous writes in the same work directory. Interrupted +package installation resumes through Portage; successful steps write +`build/manifest.json`, signature evidence and the installed package/version/USE +inventory in `build/artifacts/`. Downloaded inputs are reverified on every +prepare; missing or changed files cannot be trusted from a checkpoint alone. + +The dedicated rootless Podman store is `build/podman`, separate from default +Podman and Distrobox storage. The build container mounts inputs and repository +read-only and retains its writable layer across failures. Host networking makes +the existing local VPN/proxy available. `SYS_ADMIN` operates within the rootless +user namespace; package sandbox features incompatible with it are disabled. +No host disks or `/dev/kvm` are passed into the builder. Hydra never prunes +containers/images or changes host services. + +Official Gentoo binpkgs require signatures. USE mismatches and selective testing +keywords can cause source builds; concurrency is four compiler processes and +one emerge job. Keep licenses and Portage's `/var/db/pkg`, world and repository +for installed-system updates. Rolling binhost contents can change; resulting +versions/USE are recorded without claiming bit-identical output. The pinned +service-keyring hash may need deliberate review when Gentoo rotates keys. + +`rootfs` and `iso` are reserved assembly commands and currently return an +explicit error until the live-image assembly implementation is integrated. diff --git a/image/packages b/image/packages new file mode 100644 index 0000000..ff3e66e --- /dev/null +++ b/image/packages @@ -0,0 +1,30 @@ +# Host, boot medium, and recovery tools. Keep Portage's installed database/world. +app-emulation/qemu +~sys-firmware/edk2-bin-202408 +>=gui-wm/labwc-0.20.0 +app-emulation/virt-viewer +gui-apps/foot +media-fonts/dejavu +gui-libs/greetd +net-misc/networkmanager +net-dns/dnsmasq +net-firewall/nftables +media-video/pipewire +media-video/wireplumber +app-admin/sudo +sys-kernel/gentoo-kernel-bin +sys-kernel/linux-firmware +sys-firmware/sof-firmware +sys-firmware/intel-microcode +sys-kernel/dracut +sys-boot/grub +sys-fs/squashfs-tools +sys-fs/dosfstools +sys-fs/e2fsprogs +sys-block/parted +sys-apps/util-linux +sys-fs/mtools +dev-libs/libisoburn +dev-lang/python +sec-keys/openpgp-keys-gentoo-release +sec-keys/openpgp-keys-gentoo-auth diff --git a/image/portage/binrepos.conf/gentoo.conf b/image/portage/binrepos.conf/gentoo.conf new file mode 100644 index 0000000..25004d5 --- /dev/null +++ b/image/portage/binrepos.conf/gentoo.conf @@ -0,0 +1,4 @@ +[gentoo] +sync-uri = https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/x86-64/ +priority = 10 +verify-signature = yes diff --git a/image/portage/make.conf b/image/portage/make.conf new file mode 100644 index 0000000..bf97b4b --- /dev/null +++ b/image/portage/make.conf @@ -0,0 +1,16 @@ +COMMON_FLAGS="-O2 -pipe -march=x86-64 -mtune=generic" +CFLAGS="${COMMON_FLAGS}" +CXXFLAGS="${COMMON_FLAGS}" +FCFLAGS="${COMMON_FLAGS}" +FFLAGS="${COMMON_FLAGS}" +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" +USE="X dbus wayland opengl alsa" +QEMU_SOFTMMU_TARGETS="x86_64" +QEMU_USER_TARGETS="" +GRUB_PLATFORMS="efi-64" +BINPKG_GPG_VERIFY_GPG_HOME="/etc/portage/gnupg" +PORTAGE_NICENESS="10" diff --git a/image/portage/package.accept_keywords/hydra b/image/portage/package.accept_keywords/hydra new file mode 100644 index 0000000..dd06342 --- /dev/null +++ b/image/portage/package.accept_keywords/hydra @@ -0,0 +1,2 @@ +>=gui-wm/labwc-0.20.0 ~amd64 +gui-libs/wlroots ~amd64 diff --git a/image/portage/package.use/hydra b/image/portage/package.use/hydra new file mode 100644 index 0000000..ef08300 --- /dev/null +++ b/image/portage/package.use/hydra @@ -0,0 +1,8 @@ +app-emulation/qemu spice usb usbredir virgl opengl wayland pipewire -gtk -sdl +app-emulation/virt-viewer spice -libvirt +net-misc/spice-gtk usbredir +media-video/pipewire sound-server bluetooth dbus pipewire-alsa +net-misc/networkmanager wifi +sys-boot/grub mount +media-libs/freetype harfbuzz +sys-kernel/installkernel dracut diff --git a/image/portage/repos.conf/gentoo.conf b/image/portage/repos.conf/gentoo.conf new file mode 100644 index 0000000..95e9cae --- /dev/null +++ b/image/portage/repos.conf/gentoo.conf @@ -0,0 +1,5 @@ +[gentoo] +location = /var/db/repos/gentoo +sync-type = git +sync-uri = https://github.com/gentoo/gentoo.git +auto-sync = no diff --git a/image/sources.json b/image/sources.json new file mode 100644 index 0000000..7cd21c3 --- /dev/null +++ b/image/sources.json @@ -0,0 +1,17 @@ +{ + "stage3": { + "url": "https://distfiles.gentoo.org/releases/amd64/autobuilds/20261011T170049Z/stage3-amd64-systemd-20261011T170049Z.tar.xz", + "sha256": "50634e8cb87384b94a6dbf78cd2707766c45a5a98f3823e7287d114ee998970b", + "signature_url": "https://distfiles.gentoo.org/releases/amd64/autobuilds/20261011T170049Z/stage3-amd64-systemd-20261011T170049Z.tar.xz.asc", + "fingerprint": "13EBBDBEDE7A12775DFDB1BABB572E0E2D182910" + }, + "portage": { + "url": "https://github.com/gentoo/gentoo/archive/4696089e782c20b5f0c04952f6698d6a8cc1acb4.tar.gz", + "filename": "gentoo-4696089e782c20b5f0c04952f6698d6a8cc1acb4.tar.gz", + "sha256": "d24b2056f7ae26506193374b0d9f25c82d4bf547e0ee17279940169418bd3bca", + "commit": "4696089e782c20b5f0c04952f6698d6a8cc1acb4" + }, + "keyring_url": "https://qa-reports.gentoo.org/output/service-keys.gpg", + "keyring_sha256": "9fe40114c956c4c4d593f6e961ac9a994b0772863f4e685608fbdd8d77e39748", + "binhost": "https://distfiles.gentoo.org/releases/amd64/binpackages/23.0/x86-64/" +} diff --git a/scripts/hydra-build b/scripts/hydra-build new file mode 100755 index 0000000..560c601 --- /dev/null +++ b/scripts/hydra-build @@ -0,0 +1,9 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) +from hydra.build import main + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/tests/test_build.py b/tests/test_build.py new file mode 100644 index 0000000..d0ccb90 --- /dev/null +++ b/tests/test_build.py @@ -0,0 +1,114 @@ +import hashlib +import importlib.util +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch + + +class BuilderTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.project = Path(self.tmp.name) + spec = importlib.util.spec_from_file_location('hydra_build', Path(__file__).parents[1] / 'hydra/build.py') + self.assertIsNotNone(spec, 'builder module is required') + self.assertTrue(Path(spec.origin).is_file(), 'builder implementation is missing') + self.module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(self.module) + + def tearDown(self): + self.tmp.cleanup() + + def builder(self): + return self.module.Builder(self.project, self.project / 'build', self.project / 'cache', podman='podman') + + def test_bad_cached_hash_is_rejected(self): + artifact = self.project / 'archive' + artifact.write_bytes(b'tampered') + with self.assertRaisesRegex(self.module.BuildError, 'SHA256'): + self.module.verify_hash(artifact, hashlib.sha256(b'authentic').hexdigest()) + + def test_wrong_primary_signature_is_rejected(self): + status = '[GNUPG:] VALIDSIG ABC 2026-10-13 123 0 4 0 1 10 00 DEADBEEF\n' + with self.assertRaisesRegex(self.module.BuildError, 'fingerprint'): + self.module.require_signature(status, '13EBBDBEDE7A12775DFDB1BABB572E0E2D182910') + + def test_signature_accepts_only_successful_matching_primary(self): + fingerprint = '13EBBDBEDE7A12775DFDB1BABB572E0E2D182910' + self.module.require_signature('[GNUPG:] VALIDSIG ABC 2026-10-13 123 0 4 0 1 10 00 ' + fingerprint, fingerprint) + with self.assertRaises(self.module.BuildError): + self.module.require_signature('[GNUPG:] BADSIG ABC test', fingerprint) + + def test_refuses_root_and_outside_project(self): + with patch.object(self.module.os, 'geteuid', return_value=0): + with self.assertRaisesRegex(self.module.BuildError, 'root'): + self.builder() + with patch.object(self.module.os, 'geteuid', return_value=1000): + with self.assertRaisesRegex(self.module.BuildError, 'project'): + self.module.Builder(self.project, self.project.parent / 'unsafe', self.project / 'cache') + + def test_podman_store_is_scoped_to_work(self): + argv = self.builder().podman_argv('container', 'exists', 'builder') + self.assertEqual(argv[0], 'podman') + self.assertEqual(argv[argv.index('--root') + 1], str(self.project / 'build/podman')) + self.assertIn('--runroot', argv) + self.assertEqual(argv[-3:], ['container', 'exists', 'builder']) + + def test_resume_requires_actual_inventory(self): + builder = self.builder() + builder.work.mkdir() + (builder.work / 'manifest.json').write_text(json.dumps({'packages': {'complete': True}})) + self.assertFalse(builder.complete('packages')) + + def test_symlink_escape_is_rejected(self): + (self.project / 'escape').symlink_to(self.project.parent, target_is_directory=True) + with self.assertRaisesRegex(self.module.BuildError, 'project'): + self.module.Builder(self.project, self.project / 'escape/unsafe', self.project / 'cache') + + def test_changed_configuration_invalidates_fetch_checkpoint(self): + image = self.project / 'image' + image.mkdir() + config = image / 'sources.json' + config.write_text(json.dumps({'stage3': {'url': 'https://example.test/stage.tar', 'sha256': hashlib.sha256(b'good').hexdigest()}, 'portage': {'url': 'https://example.test/repo.tar', 'sha256': hashlib.sha256(b'repo').hexdigest()}})) + builder = self.builder() + with builder.locked(): + (builder.cache / 'stage.tar').write_bytes(b'good') + (builder.cache / 'repo.tar').write_bytes(b'repo') + builder.record('fetch') + self.assertTrue(builder.complete('fetch')) + config.write_text('{"changed":true}') + self.assertFalse(builder.complete('fetch')) + + def test_missing_fetch_archive_invalidates_checkpoint(self): + image = self.project / 'image' + image.mkdir() + (image / 'sources.json').write_text(json.dumps({'stage3': {'url': 'https://example.test/stage.tar', 'sha256': hashlib.sha256(b'good').hexdigest()}, 'portage': {'url': 'https://example.test/repo.tar', 'sha256': hashlib.sha256(b'repo').hexdigest()}})) + builder = self.builder() + with builder.locked(): + builder.record('fetch') + self.assertFalse(builder.complete('fetch')) + + def test_second_builder_cannot_share_active_work(self): + first, second = self.builder(), self.builder() + with first.locked(): + with self.assertRaisesRegex(self.module.BuildError, 'running'): + with second.locked(): + self.fail('overlapping build was allowed') + + def test_container_uses_host_proxy_network_and_readonly_inputs(self): + builder = self.builder() + args = builder.container_run_args() + self.assertEqual(args[args.index('--network') + 1], 'host') + self.assertIn(str(self.project) + ':/hydra-source:ro', args) + self.assertIn(str(self.project / 'cache') + ':/hydra-cache:ro', args) + self.assertNotIn('--privileged', args) + + def test_distinct_workspaces_have_distinct_podman_runtime_storage(self): + other = self.module.Builder(self.project, self.project / 'other-build', self.project / 'cache') + self.assertNotEqual(self.builder().runroot, other.runroot) + + +if __name__ == '__main__': + unittest.main()