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()