110 lines
5.9 KiB
Python
110 lines
5.9 KiB
Python
#!/usr/bin/env python3
|
|
"""Diskless/networkless kernel smoke boot. Run under release-heavy.lock.
|
|
|
|
Uses an already authenticated payload receipt, never installs a host package.
|
|
This proves direct kernel boot only, not ISO/EFI/GRUB or physical-node acceptance.
|
|
"""
|
|
import hashlib
|
|
import argparse
|
|
import json
|
|
from pathlib import Path
|
|
import re
|
|
import shutil
|
|
import subprocess
|
|
import sys
|
|
|
|
|
|
def main():
|
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
parser.add_argument('receipt', type=Path)
|
|
parser.add_argument('output', type=Path)
|
|
parser.add_argument('--timing-collector', type=Path, help='statically linked read-only collector')
|
|
parser.add_argument('--guest-suspend-seconds', type=int, choices=range(3, 31),
|
|
help='explicit disposable guest-only RTC wake + mem suspend experiment')
|
|
args = parser.parse_args()
|
|
if args.guest_suspend_seconds and not args.timing_collector:
|
|
parser.error('--guest-suspend-seconds requires --timing-collector')
|
|
receipt = json.loads(args.receipt.read_text())
|
|
output = args.output
|
|
output.mkdir(exist_ok=False)
|
|
release = receipt['package'].removeprefix('linux-image-')
|
|
image = '/boot/vmlinuz-' + release
|
|
with open(image, 'rb') as stream:
|
|
assert hashlib.file_digest(stream, 'sha256').hexdigest() == receipt['files'][image]
|
|
shutil.copyfile(image, output / 'vmlinuz')
|
|
root = output / 'initramfs'
|
|
for directory in ('bin', 'dev', 'proc', 'sys'):
|
|
(root / directory).mkdir(parents=True)
|
|
shutil.copyfile('/usr/bin/busybox', root / 'bin/busybox')
|
|
(root / 'bin/busybox').chmod(0o755)
|
|
dependencies = subprocess.check_output(['ldd', '/usr/bin/busybox'], text=True)
|
|
for library in re.findall(r'(/[^\s()]+)', dependencies):
|
|
target = root / library.lstrip('/')
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(library, target)
|
|
for applet in ('sh', 'mount', 'uname', 'cat', 'poweroff', 'sleep'):
|
|
(root / 'bin' / applet).symlink_to('busybox')
|
|
timing_command = ''
|
|
if args.timing_collector:
|
|
shutil.copyfile(args.timing_collector, root / 'bin/timing-collector')
|
|
(root / 'bin/timing-collector').chmod(0o755)
|
|
timing_command = '/bin/timing-collector 201 100 && echo TIMING_COLLECTION_PASS\n'
|
|
if args.guest_suspend_seconds:
|
|
# This command exists only in the generated disposable initramfs.
|
|
# No host rtcwake, power control, clock write, or attached host disk.
|
|
rtcwake = Path('/usr/sbin/rtcwake')
|
|
shutil.copy2(rtcwake, root / 'bin/rtcwake')
|
|
libraries = subprocess.check_output(['ldd', str(rtcwake)], text=True)
|
|
for library in re.findall(r'(/[^\s()]+)', libraries):
|
|
target = root / library.lstrip('/')
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copy2(library, target)
|
|
timing_command = ('/bin/timing-collector 201 100 &\ncollector=$!\nsleep 5\n'
|
|
'echo GUEST_SUSPEND_REQUEST\n'
|
|
f'/bin/rtcwake -u -m mem -s {args.guest_suspend_seconds} && echo GUEST_SUSPEND_RETURNED\n'
|
|
'wait "$collector" && echo TIMING_COLLECTION_PASS\n')
|
|
(root / 'init').write_text('#!/bin/sh\n'
|
|
'export PATH=/bin\nmount -t devtmpfs devtmpfs /dev\nexec </dev/console >/dev/console 2>&1\n'
|
|
'mount -t proc proc /proc\nmount -t sysfs sysfs /sys\n'
|
|
'uname -a\ncat /proc/version\n'
|
|
f'[ "$(uname -r)" = "{release}" ] && echo KERNEL_ARTIFACT_BOOT_PASS\n'
|
|
+ timing_command +
|
|
'poweroff -f\n')
|
|
(root / 'init').chmod(0o755)
|
|
names = '.\n' + '\n'.join(str(p.relative_to(root)) for p in sorted(root.rglob('*'))) + '\n'
|
|
with (output / 'initrd.cpio').open('wb') as stream:
|
|
subprocess.run(['cpio', '-o', '-H', 'newc'], cwd=root, input=names.encode(), stdout=stream, check=True)
|
|
command = ['qemu-system-x86_64', '-machine', 'pc-i440fx-10.0', '-accel', 'tcg', '-m', '256', '-smp', '1',
|
|
'-nodefaults', '-no-reboot', '-display', 'none', '-serial', 'stdio',
|
|
'-monitor', 'none', '-nic', 'none', '-kernel', str(output / 'vmlinuz'),
|
|
'-initrd', str(output / 'initrd.cpio'),
|
|
'-append', 'console=ttyS0 rdinit=/init panic=1']
|
|
try:
|
|
result = subprocess.run(command, capture_output=True, timeout=120)
|
|
except subprocess.TimeoutExpired as error:
|
|
# subprocess.run kills/reaps its own guest; retain partial failure evidence.
|
|
result = subprocess.CompletedProcess(command, 124, error.stdout or b'', error.stderr or b'')
|
|
(output / 'serial.log').write_bytes(result.stdout + result.stderr)
|
|
passed = result.returncode == 0 and b'KERNEL_ARTIFACT_BOOT_PASS' in result.stdout
|
|
if args.timing_collector:
|
|
passed = passed and b'TIMING_COLLECTION_PASS' in result.stdout
|
|
if args.guest_suspend_seconds:
|
|
passed = passed and b'GUEST_SUSPEND_RETURNED' in result.stdout
|
|
summary = dict(command=command, exit=result.returncode, passed=passed,
|
|
archive_sha256=receipt['archive_sha256'], kernel_sha256=receipt['files'][image])
|
|
summary['qualification_scope'] = 'direct boot and optional read-only samples; not elapsed-error qualification'
|
|
summary['guest_suspend_seconds'] = args.guest_suspend_seconds
|
|
summary['qemu_version'] = subprocess.check_output([command[0], '--version'], text=True)
|
|
summary['host_kernel'] = subprocess.check_output(['uname', '-a'], text=True).strip()
|
|
for name, path in [('receipt', args.receipt), ('initrd', output / 'initrd.cpio'),
|
|
('collector', args.timing_collector), ('harness', Path(__file__))]:
|
|
if path:
|
|
summary[name + '_sha256'] = hashlib.sha256(path.read_bytes()).hexdigest()
|
|
(output / 'result.json').write_text(json.dumps(summary, indent=2) + '\n')
|
|
print(json.dumps(summary))
|
|
return 0 if passed else 1
|
|
|
|
|
|
if __name__ == '__main__':
|
|
sys.exit(main())
|