| #!/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_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', '-c', '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() |