tricu

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

vm_guest.py (6156B)


      1 #!/usr/bin/env python3
      2 """Guest-side preparation and offline acceptance for the Ubuntu GA VM."""
      3 import hashlib
      4 import json
      5 import os
      6 from pathlib import Path
      7 import platform
      8 import shutil
      9 import subprocess
     10 import tarfile
     11 import traceback
     12 import time
     13 
     14 
     15 def digest(path):
     16     with path.open('rb') as stream:
     17         return hashlib.file_digest(stream, 'sha256').hexdigest()
     18 
     19 
     20 def command(*args):
     21     try:
     22         return subprocess.check_output(args, text=True, stderr=subprocess.STDOUT, timeout=1800)
     23     except subprocess.CalledProcessError as error:
     24         raise RuntimeError(f'{args[0]} failed ({error.returncode}): {error.output[-6000:]}') from error
     25 
     26 
     27 def unpack(path, destination, expected):
     28     if digest(path) != expected:
     29         raise ValueError(f'input hash differs: {path.name}')
     30     with tarfile.open(path, 'r:gz') as archive:
     31         archive.extractall(destination, filter='data')
     32 
     33 
     34 def identity():
     35     kernel = platform.release()
     36     return dict(kernel=kernel, machine=platform.machine(),
     37                 os_release=Path('/etc/os-release').read_text(),
     38                 proc_version=Path('/proc/version').read_text(),
     39                 kernel_sha256=digest(Path('/boot')/('vmlinuz-'+kernel)),
     40                 kernel_config_sha256=digest(Path('/boot')/('config-'+kernel)))
     41 
     42 
     43 def main():
     44     mount = Path('/mnt/tricu-input')
     45     mount.mkdir(parents=True, exist_ok=True)
     46     for _ in range(60):
     47         if Path('/dev/disk/by-label/cidata').exists() and Path('/dev/virtio-ports/tricu.report').exists():
     48             break
     49         time.sleep(1)
     50     command('mount', '-o', 'ro', '/dev/disk/by-label/cidata', str(mount))
     51     request = json.loads((mount/'request.json').read_text())
     52     observed = identity()
     53     if observed['machine'] != 'x86_64' or not observed['kernel'].startswith(request['kernel_prefix']):
     54         raise ValueError(f'unexpected guest kernel/architecture: {observed}')
     55     release = dict(line.split('=', 1) for line in observed['os_release'].splitlines() if '=' in line)
     56     if release.get('ID', '').strip('"') != 'ubuntu' or release.get('VERSION_ID', '').strip('"') != '24.04':
     57         raise ValueError('Ubuntu 24.04 guest required')
     58     if Path('/nix/store').exists():
     59         raise ValueError('guest has a Nix store')
     60     if request['mode'] == 'prepare':
     61         os.environ['DEBIAN_FRONTEND'] = 'noninteractive'
     62         for unit in ('apt-daily.timer', 'apt-daily-upgrade.timer', 'apt-daily.service',
     63                      'apt-daily-upgrade.service', 'unattended-upgrades.service'):
     64             subprocess.run(['systemctl', 'mask', '--now', unit], check=False)
     65         command('apt-get', 'update')
     66         command('apt-get', '-o', 'DPkg::Lock::Timeout=300', 'install', '-y', '--no-install-recommends',
     67                 'python3', 'php-cli', 'php-common', 'php-ffi', 'libstdc++6', 'libatomic1', 'ca-certificates')
     68         unpack(mount/'consumers.tar.gz', '/opt', request['consumers_sha256'])
     69         if command('/opt/node/bin/node', '--version').strip() != 'v'+request['node_version']:
     70             raise ValueError('Node version differs')
     71         command('/usr/bin/php', '-d', 'ffi.enable=true', '-r', 'if (!extension_loaded("FFI")) { exit(1); }')
     72         unpack(mount/'kit.tar.gz', '/kit', request['kit_sha256'])
     73         Path('/tests').symlink_to('/kit/tests')
     74         destination = Path('/opt/tricu-vm')
     75         destination.mkdir()
     76         shutil.copyfile(mount/'vm_guest.py', destination/'guest.py')
     77         (destination/'prepared.json').write_text(json.dumps(request))
     78         Path('/etc/systemd/system/tricu-accept.service').write_text('''[Unit]
     79 Description=tricu VM acceptance
     80 After=local-fs.target
     81 [Service]
     82 Type=oneshot
     83 TimeoutStartSec=1800
     84 ExecStart=/usr/bin/python3 /opt/tricu-vm/guest.py
     85 StandardOutput=journal+console
     86 StandardError=journal+console
     87 [Install]
     88 WantedBy=multi-user.target
     89 ''')
     90         # The prepared image starts the acceptance service on its next boot.
     91         command('systemctl', 'enable', 'tricu-accept.service')
     92         command('systemctl', 'mask', 'systemd-networkd-wait-online.service')
     93         Path('/etc/cloud/cloud-init.disabled').touch()
     94         return dict(status='prepared', identity=identity(), request=request,
     95                     packages=command('dpkg-query', '-W'),
     96                     node=command('/opt/node/bin/node', '--version').strip())
     97     if request['mode'] != 'run':
     98         raise ValueError('unknown guest operation')
     99     prepared = json.loads(Path('/opt/tricu-vm/prepared.json').read_text())
    100     if request['kit_sha256'] != prepared['kit_sha256'] or request['node_version'] != prepared['node_version']:
    101         raise ValueError('prepared input identity differs')
    102     work = Path('/work')
    103     work.mkdir()
    104     os.chown(work, 65534, 65534)
    105     result = subprocess.run(['/usr/sbin/runuser', '-u', 'nobody', '--', '/usr/bin/env', '-i',
    106                              'PATH=/usr/bin:/bin', 'HOME=/tmp', 'PYTHONDONTWRITEBYTECODE=1',
    107                              '/usr/bin/python3', '/kit/container_probe.py', '--expect', 'pass',
    108                              '--bindings', '--node-version', request['node_version']],
    109                             cwd=work, capture_output=True, text=True, timeout=1200)
    110     print(result.stdout, result.stderr, flush=True)
    111     if result.returncode:
    112         raise RuntimeError(f'guest workload failed: {result.stdout}\n{result.stderr}')
    113     markers = [line[len('TRICU_REPORT='):] for line in result.stdout.splitlines() if line.startswith('TRICU_REPORT=')]
    114     if len(markers) != 1:
    115         raise ValueError('missing/duplicate workload report')
    116     return dict(status='passed', identity=observed, request=request, workload=json.loads(markers[0]),
    117                 packages=command('dpkg-query', '-W'))
    118 
    119 
    120 if __name__ == '__main__':
    121     try:
    122         report = main()
    123     except BaseException:
    124         report = dict(status='failed', traceback=traceback.format_exc())
    125         print(report['traceback'], flush=True)
    126     try:
    127         with open('/dev/virtio-ports/tricu.report', 'w') as stream:
    128             stream.write(json.dumps(report, sort_keys=True)+'\n')
    129     finally:
    130         os.sync()
    131         subprocess.run(['systemctl', 'poweroff', '--no-block'], check=False)