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)