1#!/usr/bin/env python3 2 3import json 4import argparse 5import stat 6import textwrap 7import shutil 8import subprocess 9from tempfile import TemporaryDirectory 10from pathlib import Path 11import typing as T 12 13image_namespace = 'mesonbuild' 14 15image_def_file = 'image.json' 16install_script = 'install.sh' 17 18class ImageDef: 19 def __init__(self, image_dir: Path) -> None: 20 path = image_dir / image_def_file 21 data = json.loads(path.read_text(encoding='utf-8')) 22 23 assert isinstance(data, dict) 24 assert all([x in data for x in ['base_image', 'env']]) 25 assert isinstance(data['base_image'], str) 26 assert isinstance(data['env'], dict) 27 28 self.base_image: str = data['base_image'] 29 self.args: T.List[str] = data.get('args', []) 30 self.env: T.Dict[str, str] = data['env'] 31 32class BuilderBase(): 33 def __init__(self, data_dir: Path, temp_dir: Path) -> None: 34 self.data_dir = data_dir 35 self.temp_dir = temp_dir 36 37 self.common_sh = self.data_dir.parent / 'common.sh' 38 self.common_sh = self.common_sh.resolve(strict=True) 39 self.validate_data_dir() 40 41 self.image_def = ImageDef(self.data_dir) 42 43 self.docker = shutil.which('docker') 44 self.git = shutil.which('git') 45 if self.docker is None: 46 raise RuntimeError('Unable to find docker') 47 if self.git is None: 48 raise RuntimeError('Unable to find git') 49 50 def validate_data_dir(self) -> None: 51 files = [ 52 self.data_dir / image_def_file, 53 self.data_dir / install_script, 54 ] 55 if not self.data_dir.exists(): 56 raise RuntimeError(f'{self.data_dir.as_posix()} does not exist') 57 for i in files: 58 if not i.exists(): 59 raise RuntimeError(f'{i.as_posix()} does not exist') 60 if not i.is_file(): 61 raise RuntimeError(f'{i.as_posix()} is not a regular file') 62 63class Builder(BuilderBase): 64 def gen_bashrc(self) -> None: 65 out_file = self.temp_dir / 'env_vars.sh' 66 out_data = '' 67 68 # run_tests.py parameters 69 self.image_def.env['CI_ARGS'] = ' '.join(self.image_def.args) 70 71 for key, val in self.image_def.env.items(): 72 out_data += f'export {key}="{val}"\n' 73 74 # Also add /ci to PATH 75 out_data += 'export PATH="/ci:$PATH"\n' 76 77 out_file.write_text(out_data, encoding='utf-8') 78 79 # make it executable 80 mode = out_file.stat().st_mode 81 out_file.chmod(mode | stat.S_IEXEC) 82 83 def gen_dockerfile(self) -> None: 84 out_file = self.temp_dir / 'Dockerfile' 85 out_data = textwrap.dedent(f'''\ 86 FROM {self.image_def.base_image} 87 88 ADD install.sh /ci/install.sh 89 ADD common.sh /ci/common.sh 90 ADD env_vars.sh /ci/env_vars.sh 91 RUN /ci/install.sh 92 ''') 93 94 out_file.write_text(out_data, encoding='utf-8') 95 96 def do_build(self) -> None: 97 # copy files 98 for i in self.data_dir.iterdir(): 99 shutil.copy(str(i), str(self.temp_dir)) 100 shutil.copy(str(self.common_sh), str(self.temp_dir)) 101 102 self.gen_bashrc() 103 self.gen_dockerfile() 104 105 cmd_git = [self.git, 'rev-parse', '--short', 'HEAD'] 106 res = subprocess.run(cmd_git, cwd=self.data_dir, stdout=subprocess.PIPE) 107 if res.returncode != 0: 108 raise RuntimeError('Failed to get the current commit hash') 109 commit_hash = res.stdout.decode().strip() 110 111 cmd = [ 112 self.docker, 'build', 113 '-t', f'{image_namespace}/{self.data_dir.name}:latest', 114 '-t', f'{image_namespace}/{self.data_dir.name}:{commit_hash}', 115 '--pull', 116 self.temp_dir.as_posix(), 117 ] 118 if subprocess.run(cmd).returncode != 0: 119 raise RuntimeError('Failed to build the docker image') 120 121class ImageTester(BuilderBase): 122 def __init__(self, data_dir: Path, temp_dir: Path, ci_root: Path) -> None: 123 super().__init__(data_dir, temp_dir) 124 self.meson_root = ci_root.parent.parent.resolve() 125 126 def gen_dockerfile(self) -> None: 127 out_file = self.temp_dir / 'Dockerfile' 128 out_data = textwrap.dedent(f'''\ 129 FROM {image_namespace}/{self.data_dir.name} 130 131 ADD meson /meson 132 ''') 133 134 out_file.write_text(out_data, encoding='utf-8') 135 136 def copy_meson(self) -> None: 137 shutil.copytree( 138 self.meson_root, 139 self.temp_dir / 'meson', 140 ignore=shutil.ignore_patterns( 141 '.git', 142 '*_cache', 143 '__pycache__', 144 # 'work area', 145 self.temp_dir.name, 146 ) 147 ) 148 149 def do_test(self, tty: bool = False) -> None: 150 self.copy_meson() 151 self.gen_dockerfile() 152 153 try: 154 build_cmd = [ 155 self.docker, 'build', 156 '-t', 'meson_test_image', 157 self.temp_dir.as_posix(), 158 ] 159 if subprocess.run(build_cmd).returncode != 0: 160 raise RuntimeError('Failed to build the test docker image') 161 162 test_cmd = [] 163 if tty: 164 test_cmd = [ 165 self.docker, 'run', '--rm', '-t', '-i', 'meson_test_image', 166 '/bin/bash', '-c', '' 167 + 'cd meson;' 168 + 'source /ci/env_vars.sh;' 169 + f'echo -e "\\n\\nInteractive test shell in the {image_namespace}/{self.data_dir.name} container with the current meson tree";' 170 + 'echo -e "The file ci/ciimage/user.sh will be sourced if it exists to enable user specific configurations";' 171 + 'echo -e "Run the following command to run all CI tests: ./run_tests.py $CI_ARGS\\n\\n";' 172 + '[ -f ci/ciimage/user.sh ] && exec /bin/bash --init-file ci/ciimage/user.sh;' 173 + 'exec /bin/bash;' 174 ] 175 else: 176 test_cmd = [ 177 self.docker, 'run', '--rm', '-t', 'meson_test_image', 178 '/bin/bash', '-c', 'source /ci/env_vars.sh; cd meson; ./run_tests.py $CI_ARGS' 179 ] 180 181 if subprocess.run(test_cmd).returncode != 0 and not tty: 182 raise RuntimeError('Running tests failed') 183 finally: 184 cleanup_cmd = [self.docker, 'rmi', '-f', 'meson_test_image'] 185 subprocess.run(cleanup_cmd).returncode 186 187def main() -> None: 188 parser = argparse.ArgumentParser(description='Meson CI image builder') 189 parser.add_argument('what', type=str, help='Which image to build / test') 190 parser.add_argument('-t', '--type', choices=['build', 'test', 'testTTY'], help='What to do', required=True) 191 192 args = parser.parse_args() 193 194 ci_root = Path(__file__).parent 195 ci_data = ci_root / args.what 196 197 with TemporaryDirectory(prefix=f'{args.type}_{args.what}_', dir=ci_root) as td: 198 ci_build = Path(td) 199 print(f'Build dir: {ci_build}') 200 201 if args.type == 'build': 202 builder = Builder(ci_data, ci_build) 203 builder.do_build() 204 elif args.type == 'test': 205 tester = ImageTester(ci_data, ci_build, ci_root) 206 tester.do_test() 207 elif args.type == 'testTTY': 208 tester = ImageTester(ci_data, ci_build, ci_root) 209 tester.do_test(tty=True) 210 211if __name__ == '__main__': 212 main() 213