tricu

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

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