tricu

A lazy functional language based on Tree Calculus
Log | Files | Refs | README | LICENSE

vm_matrix.py (15290B)


      1 #!/usr/bin/env python3
      2 """Prepare a pinned Ubuntu 24.04 GA guest, then boot offline acceptance overlays."""
      3 import argparse
      4 import hashlib
      5 import json
      6 import os
      7 from pathlib import Path
      8 import platform
      9 import re
     10 import shutil
     11 import subprocess
     12 import tarfile
     13 import tempfile
     14 import uuid
     15 
     16 from container_matrix import inspect, load_lock, validate_bindings_kit
     17 
     18 
     19 def digest(path):
     20     with path.open('rb') as stream:
     21         return hashlib.file_digest(stream, 'sha256').hexdigest()
     22 
     23 
     24 def checked(*command, **kwargs):
     25     try:
     26         return subprocess.check_output(command, text=True, stderr=subprocess.STDOUT, timeout=300, **kwargs).strip()
     27     except subprocess.CalledProcessError as error:
     28         raise RuntimeError(f'{command[0]} failed ({error.returncode}): {error.output[-4000:]}') from error
     29 
     30 
     31 def write_json(path, value):
     32     path.write_text(json.dumps(value, indent=2, sort_keys=True)+'\n')
     33 
     34 
     35 def safe_path(value):
     36     path = Path(value).resolve()
     37     # QEMU drive options use commas as field separators.
     38     if ',' in str(path) or '\n' in str(path):
     39         raise ValueError('VM paths must be free of commas and newlines')
     40     return path
     41 
     42 
     43 def seed(directory, request, payload=None):
     44     source = directory/'seed'
     45     source.mkdir()
     46     write_json(source/'request.json', request)
     47     if request['mode'] == 'prepare':
     48         shutil.copyfile(Path(__file__).with_name('vm_guest.py'), source/'vm_guest.py')
     49         shutil.copyfile(payload/'kit.tar.gz', source/'kit.tar.gz')
     50         shutil.copyfile(payload/'consumers.tar.gz', source/'consumers.tar.gz')
     51         script = ('mkdir -p /mnt/tricu-seed; '
     52                   'mount -o ro /dev/disk/by-label/cidata /mnt/tricu-seed; '
     53                   'exec /usr/bin/python3 /mnt/tricu-seed/vm_guest.py')
     54         (source/'user-data').write_text('#cloud-config\nssh_pwauth: false\n'
     55             'runcmd:\n  - '+json.dumps(['/bin/sh', '-ec', script])+'\n')
     56     else:
     57         (source/'user-data').write_text('#cloud-config\n')
     58     (source/'meta-data').write_text('instance-id: tricu-'+uuid.uuid4().hex+'\nlocal-hostname: tricu-vm\n')
     59     target = directory/'seed.iso'
     60     checked('genisoimage', '-quiet', '-output', str(target), '-volid', 'cidata', '-joliet', '-rock', str(source))
     61     return target
     62 
     63 
     64 def qemu_command(disk, iso, directory, accel, network):
     65     return ['qemu-system-x86_64', '-machine', 'q35,accel='+accel,
     66             '-cpu', 'host' if accel == 'kvm' else 'max', '-m', '4096', '-smp', '2',
     67             '-display', 'none', '-monitor', 'none', '-no-reboot',
     68             '-serial', 'file:'+str(directory/'serial.log'),
     69             '-drive', f'file={disk},if=virtio,format=qcow2',
     70             '-drive', f'file={iso},media=cdrom,readonly=on,format=raw',
     71             '-device', 'virtio-serial-pci', '-chardev', 'file,id=report,path='+str(directory/'guest-report.json'),
     72             '-device', 'virtserialport,chardev=report,name=tricu.report',
     73             '-nic', 'user,model=virtio-net-pci' if network else 'none']
     74 
     75 
     76 def boot(disk, iso, directory, accel, network, timeout):
     77     command = qemu_command(disk, iso, directory, accel, network)
     78     write_json(directory/'qemu-command.json', command)
     79     try:
     80         with (directory/'qemu.log').open('w') as log:
     81             subprocess.run(command, stdout=log, stderr=subprocess.STDOUT, check=True, timeout=timeout)
     82     except (subprocess.CalledProcessError, subprocess.TimeoutExpired) as error:
     83         raise RuntimeError(f'VM execution failed; inspect {directory / "serial.log"} and {directory / "qemu.log"}\n'
     84                            +(directory/'qemu.log').read_text()[-2000:]) from error
     85     path = directory/'guest-report.json'
     86     if not path.exists() or not path.stat().st_size:
     87         raise ValueError(f'guest produced no report; inspect {directory / "serial.log"}')
     88     report = json.loads(path.read_text())
     89     if report.get('status') == 'failed':
     90         raise RuntimeError(report.get('traceback', 'guest failed'))
     91     return report
     92 
     93 
     94 def validate_identity(identity, prefix):
     95     if identity.get('machine') != 'x86_64' or not identity.get('kernel', '').startswith(prefix):
     96         raise ValueError('unexpected guest kernel or architecture')
     97     fields = dict(line.split('=', 1) for line in identity.get('os_release', '').splitlines() if '=' in line)
     98     if fields.get('ID', '').strip('"') != 'ubuntu' or fields.get('VERSION_ID', '').strip('"') != '24.04':
     99         raise ValueError('unexpected guest userspace')
    100     for key in ('kernel_sha256', 'kernel_config_sha256'):
    101         if not re.fullmatch(r'[0-9a-f]{64}', identity.get(key, '')):
    102             raise ValueError('missing kernel evidence')
    103 
    104 
    105 def normalize_consumers(root):
    106     """Relocate exported Node aliases and retain self-contained consumer links."""
    107     root = root.resolve()
    108     adjustments = []
    109     for path in sorted(root.rglob('*')):
    110         if not path.is_symlink():
    111             continue
    112         target = path.readlink()
    113         name = path.relative_to(root).as_posix()
    114         # The official Node image's Yarn installation lives outside /usr/local.
    115         # This payload selects Node/npm and Koffi for the CLI acceptance workload.
    116         if name in ('node/bin/yarn', 'node/bin/yarnpkg') and re.fullmatch(r'/opt/yarn-v\d+\.\d+\.\d+/bin/yarn(?:pkg)?', str(target)):
    117             path.unlink()
    118             adjustments.append(dict(path=name, original=str(target), action='omit unused Yarn alias'))
    119             continue
    120         if target.is_absolute():
    121             prefixes = {'/usr/local': root/'node', '/opt/node': root/'node', '/opt/tricu-js': root/'tricu-js'}
    122             mapped = next((destination/target.relative_to(prefix) for prefix, destination in prefixes.items()
    123                            if target.is_relative_to(prefix)), None)
    124             if mapped is None:
    125                 raise ValueError(f'consumer link points outside exported roots: {name}: {target}')
    126             relative = os.path.relpath(mapped, path.parent)
    127             path.unlink()
    128             path.symlink_to(relative)
    129             adjustments.append(dict(path=name, original=str(target), action='relativize', target=relative))
    130     for path in root.rglob('*'):
    131         if path.is_symlink() and not path.resolve(strict=True).is_relative_to(root):
    132             raise ValueError(f'consumer link escapes payload: {path.relative_to(root)}')
    133     return adjustments
    134 
    135 
    136 def validate_consumer_archive(path):
    137     # Exercise the same data filter as guest extraction before any VM boot.
    138     with tempfile.TemporaryDirectory(prefix='tricu-consumer-extraction-') as directory:
    139         with tarfile.open(path, 'r:gz') as archive:
    140             archive.extractall(directory, filter='data')
    141         root = Path(directory)
    142         for name in ('node/bin/node', 'node/lib/node_modules/npm/bin/npm-cli.js',
    143                      'tricu-js/node_modules/koffi/package.json'):
    144             if not (root/name).is_file():
    145                 raise ValueError(f'consumer archive missing {name}')
    146 
    147 
    148 def prepare(args):
    149     directory = safe_path(args.directory)
    150     image = safe_path(args.image)
    151     kit = Path(__file__).resolve().parent
    152     validate_bindings_kit(kit)
    153     if not re.fullmatch(r'[0-9a-f]{64}', args.image_sha256) or digest(image) != args.image_sha256:
    154         raise ValueError('cloud image differs from supplied SHA256')
    155     info = json.loads(checked('qemu-img', 'info', '--output=json', str(image)))
    156     if info.get('format') != 'qcow2' or info.get('backing-filename'):
    157         raise ValueError('select a self-contained qcow2 cloud image')
    158     lock = load_lock(args.container_lock)
    159     if lock.get('profile') != 'bindings':
    160         raise ValueError('select the accepted bindings-profile container lock')
    161     row = next(row for row in lock['images'] if row['name'] == 'ubuntu24')
    162     if inspect(args.engine, row['image_id'])['Id'] != row['image_id']:
    163         raise ValueError('consumer-source image identity differs')
    164     for filename, key in [('package.json', 'npm_package_sha256'), ('package-lock.json', 'npm_lock_sha256')]:
    165         if digest(kit/'bindings/ext/js'/filename) != lock[key]:
    166             raise ValueError('consumer-source npm inputs differ from kit')
    167     directory.mkdir(parents=True, exist_ok=False)
    168     consumers = directory/'consumers'
    169     consumers.mkdir()
    170     name = 'tricu-vm-export-'+uuid.uuid4().hex
    171     checked(args.engine, 'create', '--pull', 'never', '--name', name, row['image_id'])
    172     try:
    173         for component in ('node', 'tricu-js'):
    174             checked(args.engine, 'cp', name+':/opt/'+component, str(consumers/component))
    175     finally:
    176         checked(args.engine, 'rm', name)
    177     if not (consumers/'node/bin/node').is_file() or not (consumers/'tricu-js/node_modules/koffi/package.json').is_file():
    178         raise ValueError('consumer export has unexpected layout')
    179     adjustments = normalize_consumers(consumers)
    180     with tarfile.open(directory/'consumers.tar.gz', 'w:gz') as archive:
    181         for component in ('node', 'tricu-js'):
    182             archive.add(consumers/component, arcname=component)
    183     validate_consumer_archive(directory/'consumers.tar.gz')
    184     shutil.rmtree(consumers)  # Only this newly created intermediate export.
    185     with tarfile.open(directory/'kit.tar.gz', 'w:gz') as archive:
    186         for path in sorted(kit.iterdir()):
    187             archive.add(path, arcname=path.name)
    188     shutil.copyfile(image, directory/'base.qcow2')
    189     checked('qemu-img', 'create', '-f', 'qcow2', '-F', 'qcow2', '-b', 'base.qcow2', 'prepared.qcow2', cwd=directory)
    190     checked('qemu-img', 'resize', 'prepared.qcow2', '16G', cwd=directory)
    191     request = dict(mode='prepare', kernel_prefix=args.kernel_prefix, node_version=lock['node']['version'],
    192                    kit_sha256=digest(directory/'kit.tar.gz'), consumers_sha256=digest(directory/'consumers.tar.gz'))
    193     iso = seed(directory, request, directory)
    194     report = boot(directory/'prepared.qcow2', iso, directory, args.accel, True, args.timeout)
    195     if report.get('status') != 'prepared' or report.get('request') != request:
    196         raise ValueError('incomplete preparation report')
    197     validate_identity(report['identity'], args.kernel_prefix)
    198     manifest = dict(schema='tricu.vm-images.v1', request=request, identity=report['identity'],
    199                     packages=report['packages'], node=lock['node'], consumer_image=row,
    200                     cloud_image=dict(filename=image.name, sha256=args.image_sha256),
    201                     container_lock_sha256=digest(args.container_lock), consumer_adjustments=adjustments,
    202                     expected=json.loads((kit/'bindings/ext/haskell/bindings/fixtures.json').read_text())['expected'],
    203                     archive_sha256=digest(kit/'tricu-linux-x86_64.tar.gz'),
    204                     files={name: digest(directory/name) for name in ('base.qcow2', 'prepared.qcow2', 'kit.tar.gz', 'consumers.tar.gz')})
    205     write_json(directory/'images.json', manifest)
    206     print(directory/'images.json')
    207 
    208 
    209 def load_prepared(path):
    210     lock = json.loads(path.read_text())
    211     required = {'base.qcow2', 'prepared.qcow2', 'kit.tar.gz', 'consumers.tar.gz'}
    212     if lock.get('schema') != 'tricu.vm-images.v1' or set(lock.get('files', {})) != required:
    213         raise ValueError('invalid VM image lock')
    214     for name, checksum in lock['files'].items():
    215         if digest(path.parent/name) != checksum:
    216             raise ValueError(f'changed prepared VM input: {name}')
    217     if lock['request']['kit_sha256'] != lock['files']['kit.tar.gz'] or lock['request']['consumers_sha256'] != lock['files']['consumers.tar.gz']:
    218         raise ValueError('prepared payload identity differs')
    219     validate_identity(lock['identity'], lock['request']['kernel_prefix'])
    220     return lock
    221 
    222 
    223 def validate_result(report, lock, request):
    224     if report.get('status') != 'passed' or report.get('request') != request:
    225         raise ValueError('incomplete VM acceptance')
    226     if report.get('identity') != lock['identity'] or report.get('packages') != lock['packages']:
    227         raise ValueError('guest kernel/userspace differs from prepared image')
    228     workload = report['workload']
    229     if workload.get('kernel') != lock['identity']['kernel'] or workload.get('expectation') != 'pass':
    230         raise ValueError('workload kernel/expectation differs')
    231     if [run.get('location') for run in workload['runs']] != ['first', 'relocated path with spaces']:
    232         raise ValueError('incomplete relocation acceptance')
    233     for run in workload['runs']:
    234         binding = run['bindings']
    235         if not run.get('nix_store_absent') or not run.get('cli_record_verify_mutation'):
    236             raise ValueError('missing Nix-free workload controls')
    237         if binding.get('passed') != ['python', 'js', 'php'] or binding.get('node') != 'v'+request['node_version']:
    238             raise ValueError('incomplete consumer acceptance')
    239         if set(binding['results']) != {'python', 'js', 'php'} or any(value != lock['expected'] for value in binding['results'].values()):
    240             raise ValueError('consumer results differ from independent expectations')
    241 
    242 
    243 def run(args):
    244     path = safe_path(args.lock)
    245     lock = load_prepared(path)
    246     directory = safe_path(args.output)
    247     directory.mkdir(parents=True, exist_ok=False)
    248     disk = directory/'run.qcow2'
    249     checked('qemu-img', 'create', '-f', 'qcow2', '-F', 'qcow2', '-b', str(path.parent/'prepared.qcow2'), str(disk))
    250     request = dict(lock['request'], mode='run')
    251     iso = seed(directory, request)
    252     report = boot(disk, iso, directory, args.accel, False, args.timeout)
    253     validate_result(report, lock, request)
    254     # The immutable backing inputs must survive the run unchanged.
    255     load_prepared(path)
    256     write_json(directory/'report.json', dict(schema='tricu.vm-acceptance.v1', guest=report,
    257                image_lock_sha256=digest(path), prepared_files=lock['files'], archive_sha256=lock['archive_sha256'],
    258                node=lock['node'], consumer_image=lock['consumer_image'], accelerator=args.accel,
    259                host_kernel=platform.release(), qemu_version=checked('qemu-system-x86_64', '--version'),
    260                seed_sha256=digest(iso), network='disabled', scope='Ubuntu 24.04 guest kernel; Python, Node and PHP CLI'))
    261     print(directory/'report.json')
    262 
    263 
    264 def main():
    265     parser = argparse.ArgumentParser(description=__doc__)
    266     sub = parser.add_subparsers(dest='action', required=True)
    267     p = sub.add_parser('prepare')
    268     p.add_argument('--image', type=Path, required=True)
    269     p.add_argument('--image-sha256', required=True)
    270     p.add_argument('--container-lock', type=Path, required=True)
    271     p.add_argument('--directory', type=Path, required=True)
    272     p.add_argument('--engine', choices=['podman', 'docker'], default='podman')
    273     p.add_argument('--kernel-prefix', default='6.8.')
    274     r = sub.add_parser('run')
    275     r.add_argument('--lock', type=Path, required=True)
    276     r.add_argument('--output', type=Path, required=True)
    277     for command in (p, r):
    278         command.add_argument('--accel', choices=['kvm', 'tcg'], default='kvm')
    279         command.add_argument('--timeout', type=int, default=2400)
    280     args = parser.parse_args()
    281     if args.accel == 'kvm' and not os.access('/dev/kvm', os.R_OK | os.W_OK):
    282         raise ValueError('KVM access required; grant device access or select --accel tcg')
    283     if args.action == 'prepare':
    284         prepare(args)
    285     else:
    286         run(args)
    287 
    288 
    289 if __name__ == '__main__':
    290     main()