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