Add verified rootless Gentoo image builder and package inputs
This commit is contained in:
@@ -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()
|
||||
Reference in New Issue
Block a user