#!/usr/bin/env python3 import json import argparse import stat import textwrap import shutil import subprocess from tempfile import TemporaryDirectory from pathlib import Path import typing as T image_namespace = 'mesonbuild' image_def_file = 'image.json' install_script = 'install.sh' class ImageDef: def __init__(self, image_dir: Path) -> None: path = image_dir / image_def_file data = json.loads(path.read_text(encoding='utf-8')) assert isinstance(data, dict) assert all([x in data for x in ['base_image', 'env']]) assert isinstance(data['base_image'], str) assert isinstance(data['env'], dict) self.base_image: str = data['base_image'] self.args: T.List[str] = data.get('args', []) self.env: T.Dict[str, str] = data['env'] class BuilderBase(): def __init__(self, data_dir: Path, temp_dir: Path) -> None: self.data_dir = data_dir self.temp_dir = temp_dir self.common_sh = self.data_dir.parent / 'common.sh' self.common_sh = self.common_sh.resolve(strict=True) self.validate_data_dir() self.image_def = ImageDef(self.data_dir) self.docker = shutil.which('docker') self.git = shutil.which('git') if self.docker is None: raise RuntimeError('Unable to find docker') if self.git is None: raise RuntimeError('Unable to find git') def validate_data_dir(self) -> None: files = [ self.data_dir / image_def_file, self.data_dir / install_script, ] if not self.data_dir.exists(): raise RuntimeError(f'{self.data_dir.as_posix()} does not exist') for i in files: if not i.exists(): raise RuntimeError(f'{i.as_posix()} does not exist') if not i.is_file(): raise RuntimeError(f'{i.as_posix()} is not a regular file') class Builder(BuilderBase): def gen_bashrc(self) -> None: out_file = self.temp_dir / 'env_vars.sh' out_data = '' # run_tests.py parameters self.image_def.env['CI_ARGS'] = ' '.join(self.image_def.args) for key, val in self.image_def.env.items(): out_data += f'export {key}="{val}"\n' # Also add /ci to PATH out_data += 'export PATH="/ci:$PATH"\n' out_data += ''' if [ -f "$HOME/.cargo/env" ]; then source "$HOME/.cargo/env" fi ''' out_file.write_text(out_data, encoding='utf-8') # make it executable mode = out_file.stat().st_mode out_file.chmod(mode | stat.S_IEXEC) def gen_dockerfile(self) -> None: out_file = self.temp_dir / 'Dockerfile' out_data = textwrap.dedent(f'''\ FROM {self.image_def.base_image} ADD install.sh /ci/install.sh ADD common.sh /ci/common.sh ADD env_vars.sh /ci/env_vars.sh RUN /ci/install.sh ''') out_file.write_text(out_data, encoding='utf-8') def do_build(self) -> None: # copy files for i in self.data_dir.iterdir(): shutil.copy(str(i), str(self.temp_dir)) shutil.copy(str(self.common_sh), str(self.temp_dir)) self.gen_bashrc() self.gen_dockerfile() cmd_git = [self.git, 'rev-parse', '--short', 'HEAD'] res = subprocess.run(cmd_git, cwd=self.data_dir, stdout=subprocess.PIPE) if res.returncode != 0: raise RuntimeError('Failed to get the current commit hash') commit_hash = res.stdout.decode().strip() cmd = [ self.docker, 'build', '-t', f'{image_namespace}/{self.data_dir.name}:latest', '-t', f'{image_namespace}/{self.data_dir.name}:{commit_hash}', '--pull', self.temp_dir.as_posix(), ] if subprocess.run(cmd).returncode != 0: raise RuntimeError('Failed to build the docker image') class ImageTester(BuilderBase): def __init__(self, data_dir: Path, temp_dir: Path, ci_root: Path) -> None: super().__init__(data_dir, temp_dir) self.meson_root = ci_root.parent.parent.resolve() def gen_dockerfile(self) -> None: out_file = self.temp_dir / 'Dockerfile' out_data = textwrap.dedent(f'''\ FROM {image_namespace}/{self.data_dir.name} ADD meson /meson ''') out_file.write_text(out_data, encoding='utf-8') def copy_meson(self) -> None: shutil.copytree( self.meson_root, self.temp_dir / 'meson', ignore=shutil.ignore_patterns( '.git', '*_cache', '__pycache__', # 'work area', self.temp_dir.name, ) ) def do_test(self, tty: bool = False) -> None: self.copy_meson() self.gen_dockerfile() try: build_cmd = [ self.docker, 'build', '-t', 'meson_test_image', self.temp_dir.as_posix(), ] if subprocess.run(build_cmd).returncode != 0: raise RuntimeError('Failed to build the test docker image') test_cmd = [] if tty: test_cmd = [ self.docker, 'run', '--rm', '-t', '-i', 'meson_test_image', '/bin/bash', '-c', '' + 'cd meson;' + 'source /ci/env_vars.sh;' + f'echo -e "\\n\\nInteractive test shell in the {image_namespace}/{self.data_dir.name} container with the current meson tree";' + 'echo -e "The file ci/ciimage/user.sh will be sourced if it exists to enable user specific configurations";' + 'echo -e "Run the following command to run all CI tests: ./run_tests.py $CI_ARGS\\n\\n";' + '[ -f ci/ciimage/user.sh ] && exec /bin/bash --init-file ci/ciimage/user.sh;' + 'exec /bin/bash;' ] else: test_cmd = [ self.docker, 'run', '--rm', '-t', 'meson_test_image', '/bin/bash', '-xc', 'source /ci/env_vars.sh; cd meson; ./run_tests.py $CI_ARGS' ] if subprocess.run(test_cmd).returncode != 0 and not tty: raise RuntimeError('Running tests failed') finally: cleanup_cmd = [self.docker, 'rmi', '-f', 'meson_test_image'] subprocess.run(cleanup_cmd).returncode class ImageTTY(BuilderBase): def __init__(self, data_dir: Path, temp_dir: Path, ci_root: Path) -> None: super().__init__(data_dir, temp_dir) self.meson_root = ci_root.parent.parent.resolve() def do_run(self) -> None: try: tty_cmd = [ self.docker, 'run', '--name', 'meson_test_container', '-t', '-i', '-v', f'{self.meson_root.as_posix()}:/meson', f'{image_namespace}/{self.data_dir.name}', '/bin/bash', '-c', '' + 'cd meson;' + 'source /ci/env_vars.sh;' + f'echo -e "\\n\\nInteractive test shell in the {image_namespace}/{self.data_dir.name} container with the current meson tree";' + 'echo -e "The file ci/ciimage/user.sh will be sourced if it exists to enable user specific configurations";' + 'echo -e "Run the following command to run all CI tests: ./run_tests.py $CI_ARGS\\n\\n";' + '[ -f ci/ciimage/user.sh ] && exec /bin/bash --init-file ci/ciimage/user.sh;' + 'exec /bin/bash;' ] subprocess.run(tty_cmd).returncode != 0 finally: cleanup_cmd = [self.docker, 'rm', '-f', 'meson_test_container'] subprocess.run(cleanup_cmd).returncode def main() -> None: parser = argparse.ArgumentParser(description='Meson CI image builder') parser.add_argument('what', type=str, help='Which image to build / test') parser.add_argument('-t', '--type', choices=['build', 'test', 'testTTY', 'TTY'], help='What to do', required=True) args = parser.parse_args() ci_root = Path(__file__).parent ci_data = ci_root / args.what with TemporaryDirectory(prefix=f'{args.type}_{args.what}_', dir=ci_root) as td: ci_build = Path(td) print(f'Build dir: {ci_build}') if args.type == 'build': builder = Builder(ci_data, ci_build) builder.do_build() elif args.type == 'test': tester = ImageTester(ci_data, ci_build, ci_root) tester.do_test() elif args.type == 'testTTY': tester = ImageTester(ci_data, ci_build, ci_root) tester.do_test(tty=True) elif args.type == 'TTY': tester = ImageTTY(ci_data, ci_build, ci_root) tester.do_run() if __name__ == '__main__': main()