fix: preserve installed helpers, adaptations and inventory provenance
This commit is contained in:
+33
-10
@@ -212,14 +212,16 @@ if path.exists():
|
||||
if not entry.get('complete') or entry.get('inputs') != self.input_id():
|
||||
return False
|
||||
if step in ('rootfs', 'iso'):
|
||||
from . import image
|
||||
from hydra 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():
|
||||
try:
|
||||
self.package_inventory()
|
||||
except (BuildError, OSError):
|
||||
return False
|
||||
return self.complete('prepare')
|
||||
if step == 'prepare':
|
||||
@@ -244,6 +246,16 @@ if path.exists():
|
||||
return True
|
||||
return False
|
||||
|
||||
def package_inventory(self):
|
||||
from hydra import image
|
||||
checksum = self.manifest().get('packages', {}).get('inventory_sha256')
|
||||
if not isinstance(checksum, str) or not re.fullmatch('[a-f0-9]{64}', checksum):
|
||||
raise BuildError('Package inventory has no recorded checksum')
|
||||
try:
|
||||
return image.read_package_inventory(self.work / 'artifacts/packages.json', checksum)
|
||||
except (image.ImageError, OSError) as error:
|
||||
raise BuildError(f'Package inventory validation failed: {error}') from error
|
||||
|
||||
def download(self, url, filename=None):
|
||||
parsed = urllib.parse.urlparse(url)
|
||||
if parsed.scheme != 'https':
|
||||
@@ -345,8 +357,17 @@ fi'''
|
||||
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', patched_source=patch_id)
|
||||
from hydra import image
|
||||
try:
|
||||
image.validate_package_inventory(json.loads(inventory))
|
||||
except (ValueError, image.ImageError) as error:
|
||||
raise BuildError(f'Package inventory is malformed: {error}') from error
|
||||
target = self.work / 'artifacts/packages.json'
|
||||
temporary = target.with_suffix('.tmp')
|
||||
temporary.write_text(inventory)
|
||||
temporary.replace(target)
|
||||
self.record('packages', inventory='artifacts/packages.json', patched_source=patch_id,
|
||||
inventory_sha256=image.digest(target))
|
||||
|
||||
def assembly(self, snapshot_digest=None):
|
||||
"""A separate rootless container processes an exported copy, never the builder."""
|
||||
@@ -378,7 +399,7 @@ fi'''
|
||||
return name
|
||||
|
||||
def rootfs(self):
|
||||
from . import image
|
||||
from hydra import image
|
||||
if self.active_package_job():
|
||||
raise BuildError('A contained package job is running; wait before export')
|
||||
if not self.complete('packages'):
|
||||
@@ -412,15 +433,17 @@ chmod 0644 /hydra-output/artifacts/vmlinuz /hydra-output/artifacts/initramfs-liv
|
||||
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')
|
||||
if manifest.get('packages') != self.package_inventory():
|
||||
raise BuildError('Payload package inventory differs from the validated package checkpoint')
|
||||
if manifest.get('source_files') != image.source_files(self.source_dir):
|
||||
raise BuildError('Payload 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')}
|
||||
('vmlinuz', 'initramfs-live.img', 'squashfs.img', 'rootfs-manifest.json', 'packages.json')}
|
||||
self.record('rootfs', source_inputs=source_inputs, snapshot_sha256=snapshot,
|
||||
assembler=assembler, kernel=manifest['kernel'], artifacts=artifacts)
|
||||
|
||||
def iso(self):
|
||||
from . import image
|
||||
from hydra import image
|
||||
if not self.complete('rootfs'):
|
||||
raise BuildError('Verified rootfs artifacts are required before ISO assembly')
|
||||
if self.complete('iso'):
|
||||
@@ -450,7 +473,7 @@ chmod 0644 /hydra-output/artifacts/hydra-os.iso /hydra-output/artifacts/iso-insp
|
||||
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')}
|
||||
('hydra-os.iso', 'hydra-os.iso.sha256', 'iso-inspection.txt', 'packages.json')}
|
||||
self.record('iso', source_inputs=source_inputs, label=image.LABEL, artifacts=artifacts)
|
||||
|
||||
def status(self):
|
||||
|
||||
+50
-1
@@ -154,6 +154,50 @@ def artifacts_valid(directory, state):
|
||||
return True
|
||||
|
||||
|
||||
def validate_package_inventory(data):
|
||||
if not isinstance(data, list) or not data:
|
||||
raise ImageError('Package inventory must be a nonempty array')
|
||||
seen = set()
|
||||
for record in data:
|
||||
if not isinstance(record, dict) or set(record) != {'package', 'use', 'build_time'}:
|
||||
raise ImageError('Malformed package inventory entry')
|
||||
package = record['package']
|
||||
if (not isinstance(package, str) or
|
||||
not re.fullmatch(r'[A-Za-z0-9+_.-]+/[A-Za-z0-9+_.-]+-[0-9][A-Za-z0-9+_.-]*', package) or
|
||||
package in seen):
|
||||
raise ImageError('Invalid or duplicate package inventory identity')
|
||||
seen.add(package)
|
||||
flags = record['use']
|
||||
if (not isinstance(flags, list) or any(not isinstance(flag, str) or
|
||||
not re.fullmatch(r'[A-Za-z0-9+_.@-]+', flag) for flag in flags) or len(set(flags)) != len(flags)):
|
||||
raise ImageError('Invalid package inventory USE flags')
|
||||
built = record['build_time']
|
||||
if built is not None and (not isinstance(built, str) or not re.fullmatch(r'[0-9]+', built)):
|
||||
raise ImageError('Invalid package inventory build time')
|
||||
return data
|
||||
|
||||
|
||||
def read_package_inventory(path, expected_digest=None):
|
||||
path = Path(path)
|
||||
if path.is_symlink() or not path.is_file():
|
||||
raise ImageError('Package inventory must be an ordinary file')
|
||||
if expected_digest is not None and digest(path) != expected_digest:
|
||||
raise ImageError('Package inventory checksum differs from checkpoint')
|
||||
try:
|
||||
return validate_package_inventory(json.loads(path.read_text()))
|
||||
except (ValueError, UnicodeError) as error:
|
||||
raise ImageError('Package inventory is not valid JSON') from error
|
||||
|
||||
|
||||
def enable_user_audio(root):
|
||||
root_path(root, 'etc/systemd/user')
|
||||
result = subprocess.run(['systemctl', '--root=' + str(root), '--global', 'enable',
|
||||
'pipewire.socket', 'pipewire-pulse.socket', 'wireplumber.service'],
|
||||
text=True, capture_output=True)
|
||||
if result.returncode:
|
||||
raise ImageError('Cannot enable user audio sockets: ' + result.stderr.strip())
|
||||
|
||||
|
||||
def configure_rootfs(root, source):
|
||||
root, source = Path(root), Path(source)
|
||||
# Refuse incomplete integrations before changing the exported copy.
|
||||
@@ -169,6 +213,9 @@ def configure_rootfs(root, source):
|
||||
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))
|
||||
# Session helpers derive this checkout-compatible launcher from __file__.
|
||||
for name in ('hydra', 'hydra-install'):
|
||||
write(root, 'usr/lib/hydra/bin/' + name, (source / 'bin' / name).read_text(), 0o755)
|
||||
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'
|
||||
@@ -271,7 +318,7 @@ def digest(path):
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument('step', choices=('configure', 'kernel', 'network'))
|
||||
parser.add_argument('step', choices=('configure', 'kernel', 'network', 'audio'))
|
||||
parser.add_argument('root', nargs='?', type=Path)
|
||||
parser.add_argument('--source', type=Path)
|
||||
options = parser.parse_args(argv)
|
||||
@@ -282,6 +329,8 @@ def main(argv=None):
|
||||
raise ImageError('An isolated rootfs directory is required')
|
||||
if options.step == 'kernel':
|
||||
print(kernel_version(options.root))
|
||||
elif options.step == 'audio':
|
||||
enable_user_audio(options.root)
|
||||
else:
|
||||
if options.source is None:
|
||||
raise ImageError('Source checkout is required')
|
||||
|
||||
Reference in New Issue
Block a user