container_matrix.py (14497B)
1 #!/usr/bin/env python3 2 """Prepare pinned local distro test images, then run an offline acceptance matrix.""" 3 import argparse 4 import hashlib 5 import json 6 import os 7 from pathlib import Path 8 import re 9 import shutil 10 import subprocess 11 import sys 12 import tempfile 13 import uuid 14 15 MATRIX = { 16 'ubuntu24': ('docker.io/library/ubuntu:24.04', 'apt', 'pass'), 17 'debian13': ('docker.io/library/debian:13', 'apt', 'pass'), 18 'fedora': ('registry.fedoraproject.org/fedora:latest', 'dnf', 'pass'), 19 'ubuntu22': ('docker.io/library/ubuntu:22.04', 'apt', 'glibc-rejection'), 20 } 21 22 23 def checked(command, **kwargs): 24 return subprocess.run(command, check=True, text=True, capture_output=True, **kwargs).stdout 25 26 27 def inspect(engine, image): 28 row = json.loads(checked([engine, 'image', 'inspect', image], timeout=60))[0] 29 if row['Architecture'] != 'amd64' or row['Os'] != 'linux': 30 raise ValueError('matrix requires native Linux amd64 images') 31 if re.fullmatch(r'[0-9a-f]{64}', row['Id']): 32 row['Id'] = 'sha256:'+row['Id'] 33 if not re.fullmatch(r'sha256:[0-9a-f]{64}', row['Id']): 34 raise ValueError('invalid image content identity') 35 return row 36 37 38 BINDING_FILES = ( 39 'ext/js/package.json', 'ext/js/package-lock.json', 'ext/js/src/lib.js', 40 'ext/js/test/conformance.mjs', 'ext/php/src/ffi.php', 'ext/php/tests/conformance.php', 41 'ext/haskell/bindings/python.py', 'ext/haskell/bindings/fixtures.json', 42 'ext/haskell/test_embedding.py', 'ext/js/test/runner.mjs', 43 ) 44 45 46 def validate_bindings_kit(kit): 47 missing = [name for name in BINDING_FILES if not (kit/'bindings'/name).is_file()] 48 if missing: 49 raise ValueError(f'incomplete bindings test kit: {missing}') 50 51 52 def prepare(args): 53 if args.distro and len(args.distro) != len(set(args.distro)): 54 raise ValueError('duplicate distro selection') 55 if args.bindings != bool(args.node_version): 56 raise ValueError('--bindings and --node-version must be selected together') 57 if args.node_version and (not re.fullmatch(r'\d+\.\d+\.\d+', args.node_version) 58 or int(args.node_version.split('.')[0]) < 22): 59 raise ValueError('select an exact upstream Node version >=22') 60 kit = Path(__file__).resolve().parent 61 if args.bindings: 62 validate_bindings_kit(kit) 63 args.directory.mkdir(parents=True, exist_ok=False) 64 node = None 65 if args.bindings: 66 image = f'docker.io/library/node:{args.node_version}-bookworm-slim' 67 checked([args.engine, 'pull', '--platform', 'linux/amd64', image], timeout=900) 68 upstream = inspect(args.engine, image) 69 digest = next((d for d in upstream.get('RepoDigests', []) 70 if re.fullmatch(r'[^\s]+@sha256:[0-9a-f]{64}', d)), None) 71 if digest is None: 72 raise ValueError('upstream Node image has no registry digest') 73 node = dict(version=args.node_version, image=image, digest=digest, image_id=upstream['Id']) 74 rows = [] 75 for name in args.distro or MATRIX: 76 image, manager, expected = MATRIX[name] 77 checked([args.engine, 'pull', '--platform', 'linux/amd64', image], timeout=900) 78 base = inspect(args.engine, image) 79 digest = next((d for d in base.get('RepoDigests', []) 80 if re.fullmatch(r'[^\s]+@sha256:[0-9a-f]{64}', d)), None) 81 if digest is None: 82 raise ValueError(f'no registry digest for {image}') 83 # Record the exact base and execute installation only during preparation. 84 install = ('apt-get update && apt-get install -y --no-install-recommends python3 ' 85 '&& rm -rf /var/lib/apt/lists/*') if manager == 'apt' else 'dnf -y install python3 && dnf clean all' 86 context = args.directory/name 87 context.mkdir() 88 dockerfile = f'FROM {digest}\nRUN {install}\n' 89 if args.bindings: 90 packages = ('apt-get update && apt-get install -y --no-install-recommends php-cli php-common php-ffi ca-certificates libstdc++6 libatomic1 ' 91 '&& rm -rf /var/lib/apt/lists/*') if manager == 'apt' else 'dnf -y install php-cli php-common php-ffi ca-certificates libstdc++ libatomic && dnf clean all' 92 for filename in ('package.json', 'package-lock.json'): 93 shutil.copyfile(kit/'bindings/ext/js'/filename, context/filename) 94 dockerfile += (f'RUN {packages}\nCOPY --from={node["digest"]} /usr/local/ /opt/node/\n' 95 'ENV PATH=/opt/node/bin:/usr/bin:/bin\n' 96 'COPY package.json package-lock.json /opt/tricu-js/\n' 97 'RUN cd /opt/tricu-js && npm ci --omit=dev && npm cache clean --force\n' 98 'RUN php -d ffi.enable=true -r \'if (!extension_loaded("FFI")) { exit(1); }\'\n') 99 (context/'Dockerfile').write_text(dockerfile) 100 tag = 'tricu-distro-test:'+name+'-'+uuid.uuid4().hex 101 result = subprocess.run([args.engine, 'build', '--platform', 'linux/amd64', 102 '--tag', tag, str(context)], 103 text=True, capture_output=True, timeout=1800) 104 (context/'build.log').write_text(result.stdout+result.stderr) 105 result.check_returncode() 106 prepared = inspect(args.engine, tag) 107 packages = checked([args.engine, 'run', '--rm', '--network', 'none', 108 '--entrypoint', '/bin/sh', prepared['Id'], '-c', 109 "dpkg-query -W" if manager == 'apt' else "rpm -qa | sort"], timeout=60) 110 (context/'packages.txt').write_text(packages) 111 rows.append(dict(name=name, requested_image=image, base_digest=digest, 112 base_id=base['Id'], image_id=prepared['Id'], expectation=expected, 113 dockerfile_sha256=hashlib.sha256(dockerfile.encode()).hexdigest(), 114 packages_sha256=hashlib.sha256(packages.encode()).hexdigest())) 115 lock = dict(schema='tricu.distro-images.v1', engine=args.engine, images=rows) 116 if args.bindings: 117 lock.update(profile='bindings', node=node, 118 npm_lock_sha256=hashlib.sha256((kit/'bindings/ext/js/package-lock.json').read_bytes()).hexdigest(), 119 npm_package_sha256=hashlib.sha256((kit/'bindings/ext/js/package.json').read_bytes()).hexdigest()) 120 (args.directory/'images.json').write_text(json.dumps(lock, indent=2)+'\n') 121 print(args.directory/'images.json') 122 123 124 def load_lock(path): 125 lock = json.loads(path.read_text()) 126 if lock.get('schema') != 'tricu.distro-images.v1' or not lock.get('images'): 127 raise ValueError('invalid/empty image lock') 128 if lock.get('profile', 'python') not in ('python', 'bindings'): 129 raise ValueError('unknown runtime profile') 130 if lock.get('profile') == 'bindings': 131 node = lock['node'] 132 if not re.fullmatch(r'[^\s]+@sha256:[0-9a-f]{64}', node['digest']): 133 raise ValueError('upstream Node must be digest-pinned') 134 if not re.fullmatch(r'\d+\.\d+\.\d+', node['version']) or int(node['version'].split('.')[0]) < 22: 135 raise ValueError('exact upstream Node >=22 required') 136 names = set() 137 for row in lock['images']: 138 if row['name'] not in MATRIX or row['name'] in names: 139 raise ValueError('unknown/duplicate distro') 140 names.add(row['name']) 141 if row['expectation'] != MATRIX[row['name']][2]: 142 raise ValueError('changed acceptance expectation') 143 if not re.fullmatch(r'sha256:[0-9a-f]{64}', row['image_id']): 144 raise ValueError('image must be pinned by content ID') 145 evidence = [('Dockerfile', 'dockerfile_sha256'), ('packages.txt', 'packages_sha256')] 146 if lock.get('profile') == 'bindings': 147 dockerfile = (path.parent/row['name']/'Dockerfile').read_text() 148 if f'COPY --from={lock["node"]["digest"]} /usr/local/ /opt/node/' not in dockerfile: 149 raise ValueError('Node provenance differs from prepared recipe') 150 for file, key in [('package.json', 'npm_package_sha256'), ('package-lock.json', 'npm_lock_sha256')]: 151 if hashlib.sha256((path.parent/row['name']/file).read_bytes()).hexdigest() != lock[key]: 152 raise ValueError('changed Koffi preparation inputs') 153 for file, key in evidence: 154 content = (path.parent/row['name']/file).read_bytes() 155 if hashlib.sha256(content).hexdigest() != row[key]: 156 raise ValueError('changed prepared-image evidence') 157 return lock 158 159 160 def run(args): 161 lock = load_lock(args.lock) 162 args.output.mkdir(parents=True, exist_ok=False) 163 kit = Path(__file__).resolve().parent 164 bindings = lock.get('profile') == 'bindings' 165 if bindings: 166 validate_bindings_kit(kit) 167 for filename, key in [('package-lock.json', 'npm_lock_sha256'), ('package.json', 'npm_package_sha256')]: 168 if hashlib.sha256((kit/'bindings/ext/js'/filename).read_bytes()).hexdigest() != lock[key]: 169 raise ValueError('prepared Koffi inputs differ from current kit; prepare new images') 170 results = [] 171 with tempfile.TemporaryDirectory(prefix='tricu-distro-kit-') as directory: 172 # Copy the transfer kit into a standalone mount for rootless/SELinux engines. 173 copied = Path(directory)/'kit' 174 shutil.copytree(kit, copied) 175 for path in [copied, *copied.rglob('*')]: 176 path.chmod(0o755 if path.is_dir() else 0o644) 177 for row in lock['images']: 178 actual = inspect(args.engine, row['image_id']) 179 if actual['Id'] != row['image_id']: 180 raise ValueError('prepared image identity changed') 181 name = 'tricu-acceptance-'+uuid.uuid4().hex 182 command = [args.engine, 'run', '--rm', '--pull', 'never', '--name', name, 183 '--platform', 'linux/amd64', '--network', 'none', '--read-only', 184 '--cap-drop', 'ALL', '--security-opt', 'no-new-privileges', 185 '--memory', '1g', '--pids-limit', '128', '--user', '65534:65534', 186 '--tmpfs', '/work:rw,size=256m,mode=1777', 187 '--tmpfs', '/tmp:rw,size=64m,mode=1777', 188 '--volume', str(copied)+':/kit:ro,Z', 189 '--volume', str(copied/'tests')+':/tests:ro,Z', 190 '--workdir', '/work', '--entrypoint', '/usr/bin/env', row['image_id'], 191 '-i', 'PATH=/usr/bin:/bin', 'HOME=/tmp', 'PYTHONDONTWRITEBYTECODE=1', 192 '/usr/bin/python3', '/kit/container_probe.py', '--expect', row['expectation']] 193 if bindings: 194 command += ['--bindings', '--node-version', lock['node']['version']] 195 try: 196 result = subprocess.run(command, text=True, capture_output=True, timeout=1200 if bindings else 600) 197 log = args.output/(row['name']+'.log') 198 log.write_text(result.stdout+result.stderr) 199 if result.returncode: 200 raise RuntimeError(f'{row["name"]} failed (exit {result.returncode}); log: {log}\n' 201 +(result.stdout+result.stderr)[-4000:]) 202 markers = [line.removeprefix('TRICU_REPORT=') for line in result.stdout.splitlines() 203 if line.startswith('TRICU_REPORT=')] 204 if len(markers) != 1: 205 raise ValueError('missing/duplicate container acceptance report') 206 report = json.loads(markers[0]) 207 if report.get('expectation') != row['expectation'] or len(report.get('runs', [])) != 2: 208 raise ValueError('incomplete container report') 209 if bindings and row['expectation'] == 'pass': 210 if any(run.get('bindings', {}).get('passed') != ['python', 'js', 'php'] for run in report['runs']): 211 raise ValueError('incomplete binding acceptance report') 212 results.append(dict(image=row, report=report)) 213 (args.output/(row['name']+'.json')).write_text(json.dumps(results[-1], indent=2)+'\n') 214 except subprocess.TimeoutExpired as error: 215 (args.output/(row['name']+'.log')).write_bytes((error.stdout or b'')+(error.stderr or b'')) 216 raise 217 finally: 218 try: 219 subprocess.run([args.engine, 'rm', '--force', name], capture_output=True, timeout=30) 220 except subprocess.TimeoutExpired: 221 print(f'Container cleanup timed out: {name}', file=sys.stderr) 222 report = dict(schema='tricu.distro-acceptance.v1', results=results, 223 engine_version=checked([args.engine, '--version'], timeout=30).strip(), 224 image_lock_sha256=hashlib.sha256(args.lock.read_bytes()).hexdigest(), 225 kit_files={str(path.relative_to(kit)): hashlib.sha256(path.read_bytes()).hexdigest() 226 for path in sorted(kit.rglob('*')) if path.is_file()}, 227 archive_sha256=hashlib.sha256((kit/'tricu-linux-x86_64.tar.gz').read_bytes()).hexdigest(), 228 profile=lock.get('profile', 'python'), node=lock.get('node'), 229 scope=('Distro Python/PHP and upstream Node userspace acceptance; shared host kernel' if bindings 230 else 'Distro Python/userspace acceptance; containers share the host kernel')) 231 (args.output/'report.json').write_text(json.dumps(report, indent=2)+'\n') 232 print(args.output/'report.json') 233 234 235 def main(): 236 p = argparse.ArgumentParser(description=__doc__) 237 sub = p.add_subparsers(dest='action', required=True) 238 prepare_parser = sub.add_parser('prepare') 239 prepare_parser.add_argument('--directory', type=Path, required=True) 240 prepare_parser.add_argument('--distro', choices=MATRIX, action='append') 241 prepare_parser.add_argument('--bindings', action='store_true') 242 prepare_parser.add_argument('--node-version', help='exact upstream Node release, e.g. 22.22.0') 243 run_parser = sub.add_parser('run') 244 run_parser.add_argument('--lock', type=Path, required=True) 245 run_parser.add_argument('--output', type=Path, required=True) 246 for parser in (prepare_parser, run_parser): 247 parser.add_argument('--engine', choices=['docker', 'podman'], default='podman') 248 args = p.parse_args() 249 if args.action == 'prepare': 250 prepare(args) 251 else: 252 run(args) 253 254 255 if __name__ == '__main__': 256 main()