|
| 1 | +from collections import namedtuple |
| 2 | +import os |
| 3 | +import json |
| 4 | +import platform |
| 5 | +import shutil |
| 6 | +import yaml |
| 7 | +from conan.api.model import ListPattern |
| 8 | +from conan.api.output import Color, ConanOutput |
| 9 | +from conan.api.conan_api import ConfigAPI |
| 10 | +from conan.cli import make_abs_path |
| 11 | +from conan.internal.runner import RunnerException |
| 12 | +from conans.client.profile_loader import ProfileLoader |
| 13 | +from conans.errors import ConanException |
| 14 | +from conans.model.version import Version |
| 15 | + |
| 16 | + |
| 17 | +def config_parser(file_path): |
| 18 | + Build = namedtuple('Build', ['dockerfile', 'build_context', 'build_args', 'cache_from']) |
| 19 | + Run = namedtuple('Run', ['name', 'environment', 'user', 'privileged', 'cap_add', 'security_opt', 'volumes']) |
| 20 | + Conf = namedtuple('Conf', ['image', 'build', 'run']) |
| 21 | + if file_path: |
| 22 | + def _instans_or_error(value, obj): |
| 23 | + if value and (not isinstance(value, obj)): |
| 24 | + raise ConanException(f"docker runner configfile syntax error: {value} must be a {obj.__name__}") |
| 25 | + return value |
| 26 | + with open(file_path, 'r') as f: |
| 27 | + runnerfile = yaml.safe_load(f) |
| 28 | + return Conf( |
| 29 | + image=_instans_or_error(runnerfile.get('image'), str), |
| 30 | + build=Build( |
| 31 | + dockerfile=_instans_or_error(runnerfile.get('build', {}).get('dockerfile'), str), |
| 32 | + build_context=_instans_or_error(runnerfile.get('build', {}).get('build_context'), str), |
| 33 | + build_args=_instans_or_error(runnerfile.get('build', {}).get('build_args'), dict), |
| 34 | + cache_from=_instans_or_error(runnerfile.get('build', {}).get('cacheFrom'), list), |
| 35 | + ), |
| 36 | + run=Run( |
| 37 | + name=_instans_or_error(runnerfile.get('run', {}).get('name'), str), |
| 38 | + environment=_instans_or_error(runnerfile.get('run', {}).get('containerEnv'), dict), |
| 39 | + user=_instans_or_error(runnerfile.get('run', {}).get('containerUser'), str), |
| 40 | + privileged=_instans_or_error(runnerfile.get('run', {}).get('privileged'), bool), |
| 41 | + cap_add=_instans_or_error(runnerfile.get('run', {}).get('capAdd'), list), |
| 42 | + security_opt=_instans_or_error(runnerfile.get('run', {}).get('securityOpt'), list), |
| 43 | + volumes=_instans_or_error(runnerfile.get('run', {}).get('mounts'), dict), |
| 44 | + ) |
| 45 | + ) |
| 46 | + else: |
| 47 | + return Conf( |
| 48 | + image=None, |
| 49 | + build=Build(dockerfile=None, build_context=None, build_args=None, cache_from=None), |
| 50 | + run=Run(name=None, environment=None, user=None, privileged=None, cap_add=None, security_opt=None, volumes=None) |
| 51 | + ) |
| 52 | + |
| 53 | + |
| 54 | +def _docker_info(msg, error=False): |
| 55 | + fg=Color.BRIGHT_MAGENTA |
| 56 | + if error: |
| 57 | + fg=Color.BRIGHT_RED |
| 58 | + ConanOutput().status('\n┌'+'─'*(2+len(msg))+'┐', fg=fg) |
| 59 | + ConanOutput().status(f'| {msg} |', fg=fg) |
| 60 | + ConanOutput().status('└'+'─'*(2+len(msg))+'┘\n', fg=fg) |
| 61 | + |
| 62 | + |
| 63 | +class DockerRunner: |
| 64 | + def __init__(self, conan_api, command, host_profile, build_profile, args, raw_args): |
| 65 | + import docker |
| 66 | + import docker.api.build |
| 67 | + try: |
| 68 | + self.docker_client = docker.from_env() |
| 69 | + self.docker_api = docker.APIClient() |
| 70 | + docker.api.build.process_dockerfile = lambda dockerfile, path: ('Dockerfile', dockerfile) |
| 71 | + except: |
| 72 | + raise ConanException("Docker Client failed to initialize." |
| 73 | + "\n - Check if docker is installed and running" |
| 74 | + "\n - Run 'pip install pip install conan[runners]'") |
| 75 | + self.conan_api = conan_api |
| 76 | + self.build_profile = build_profile |
| 77 | + self.args = args |
| 78 | + self.abs_host_path = make_abs_path(args.path) |
| 79 | + if args.format: |
| 80 | + raise ConanException("format argument is forbidden if running in a docker runner") |
| 81 | + |
| 82 | + # Runner config |
| 83 | + self.abs_runner_home_path = os.path.join(self.abs_host_path, '.conanrunner') |
| 84 | + self.abs_docker_path = os.path.join('/root/conanrunner', os.path.basename(self.abs_host_path)).replace("\\","/") |
| 85 | + |
| 86 | + # Update conan command and some paths to run inside the container |
| 87 | + raw_args[raw_args.index(args.path)] = self.abs_docker_path |
| 88 | + self.profiles = [] |
| 89 | + if self.args.profile_build and self.args.profile_host: |
| 90 | + profile_list = set(self.args.profile_build + self.args.profile_host) |
| 91 | + else: |
| 92 | + profile_list = self.args.profile_host or self.args.profile_build |
| 93 | + |
| 94 | + # Update the profile paths |
| 95 | + for i, raw_arg in enumerate(raw_args): |
| 96 | + for i, raw_profile in enumerate(profile_list): |
| 97 | + _profile = ProfileLoader.get_profile_path(os.path.join(ConfigAPI(self.conan_api).home(), 'profiles'), raw_profile, os.getcwd()) |
| 98 | + _name = f'{os.path.basename(_profile)}_{i}' |
| 99 | + if raw_profile in raw_arg: |
| 100 | + raw_args[raw_args.index(raw_arg)] = raw_arg.replace(raw_profile, os.path.join(self.abs_docker_path, '.conanrunner/profiles', _name)) |
| 101 | + self.profiles.append([_profile, os.path.join(self.abs_runner_home_path, 'profiles', _name)]) |
| 102 | + |
| 103 | + self.command = ' '.join([f'conan {command}'] + [f'"{raw_arg}"' if ' ' in raw_arg else raw_arg for raw_arg in raw_args] + ['-f json > create.json']) |
| 104 | + |
| 105 | + # Container config |
| 106 | + # https://containers.dev/implementors/json_reference/ |
| 107 | + self.configfile = config_parser(host_profile.runner.get('configfile')) |
| 108 | + self.dockerfile = host_profile.runner.get('dockerfile') or self.configfile.build.dockerfile |
| 109 | + self.docker_build_context = host_profile.runner.get('build_context') or self.configfile.build.build_context |
| 110 | + self.image = host_profile.runner.get('image') or self.configfile.image |
| 111 | + if not (self.dockerfile or self.image): |
| 112 | + raise ConanException("'dockerfile' or docker image name is needed") |
| 113 | + self.image = self.image or 'conan-runner-default' |
| 114 | + self.name = self.configfile.image or f'conan-runner-{host_profile.runner.get("suffix", "docker")}' |
| 115 | + self.remove = str(host_profile.runner.get('remove', 'false')).lower() == 'true' |
| 116 | + self.cache = str(host_profile.runner.get('cache', 'clean')) |
| 117 | + self.container = None |
| 118 | + |
| 119 | + def run(self): |
| 120 | + """ |
| 121 | + run conan inside a Docker continer |
| 122 | + """ |
| 123 | + if self.dockerfile: |
| 124 | + _docker_info(f'Building the Docker image: {self.image}') |
| 125 | + self.build_image() |
| 126 | + volumes, environment = self.create_runner_environment() |
| 127 | + error = False |
| 128 | + try: |
| 129 | + if self.docker_client.containers.list(all=True, filters={'name': self.name}): |
| 130 | + _docker_info('Starting the docker container') |
| 131 | + self.container = self.docker_client.containers.get(self.name) |
| 132 | + self.container.start() |
| 133 | + else: |
| 134 | + if self.configfile.run.environment: |
| 135 | + environment.update(self.configfile.run.environment) |
| 136 | + if self.configfile.run.volumes: |
| 137 | + volumes.update(self.configfile.run.volumes) |
| 138 | + _docker_info('Creating the docker container') |
| 139 | + self.container = self.docker_client.containers.run( |
| 140 | + self.image, |
| 141 | + "/bin/bash -c 'while true; do sleep 30; done;'", |
| 142 | + name=self.name, |
| 143 | + volumes=volumes, |
| 144 | + environment=environment, |
| 145 | + user=self.configfile.run.user, |
| 146 | + privileged=self.configfile.run.privileged, |
| 147 | + cap_add=self.configfile.run.cap_add, |
| 148 | + security_opt=self.configfile.run.security_opt, |
| 149 | + detach=True, |
| 150 | + auto_remove=False) |
| 151 | + _docker_info(f'Container {self.name} running') |
| 152 | + except Exception as e: |
| 153 | + raise ConanException(f'Imposible to run the container "{self.name}" with image "{self.image}"' |
| 154 | + f'\n\n{str(e)}') |
| 155 | + try: |
| 156 | + self.init_container() |
| 157 | + self.run_command(self.command) |
| 158 | + self.update_local_cache() |
| 159 | + except ConanException as e: |
| 160 | + error = True |
| 161 | + raise e |
| 162 | + except RunnerException as e: |
| 163 | + error = True |
| 164 | + raise ConanException(f'"{e.command}" inside docker fail' |
| 165 | + f'\n\nLast command output: {str(e.stdout_log)}') |
| 166 | + finally: |
| 167 | + if self.container: |
| 168 | + error_prefix = 'ERROR: ' if error else '' |
| 169 | + _docker_info(f'{error_prefix}Stopping container', error) |
| 170 | + self.container.stop() |
| 171 | + if self.remove: |
| 172 | + _docker_info(f'{error_prefix}Removing container', error) |
| 173 | + self.container.remove() |
| 174 | + |
| 175 | + def build_image(self): |
| 176 | + dockerfile_file_path = self.dockerfile |
| 177 | + if os.path.isdir(self.dockerfile): |
| 178 | + dockerfile_file_path = os.path.join(self.dockerfile, 'Dockerfile') |
| 179 | + with open(dockerfile_file_path) as f: |
| 180 | + build_path = self.docker_build_context or os.path.dirname(dockerfile_file_path) |
| 181 | + ConanOutput().highlight(f"Dockerfile path: '{dockerfile_file_path}'") |
| 182 | + ConanOutput().highlight(f"Docker build context: '{build_path}'\n") |
| 183 | + docker_build_logs = self.docker_api.build( |
| 184 | + path=build_path, |
| 185 | + dockerfile=f.read(), |
| 186 | + tag=self.image, |
| 187 | + buildargs=self.configfile.build.build_args, |
| 188 | + cache_from=self.configfile.build.cache_from, |
| 189 | + ) |
| 190 | + for chunk in docker_build_logs: |
| 191 | + for line in chunk.decode("utf-8").split('\r\n'): |
| 192 | + if line: |
| 193 | + stream = json.loads(line).get('stream') |
| 194 | + if stream: |
| 195 | + ConanOutput().status(stream.strip()) |
| 196 | + |
| 197 | + def run_command(self, command, log=True): |
| 198 | + if log: |
| 199 | + _docker_info(f'Running in container: "{command}"') |
| 200 | + exec_instance = self.docker_api.exec_create(self.container.id, f"/bin/bash -c '{command}'", tty=True) |
| 201 | + exec_output = self.docker_api.exec_start(exec_instance['Id'], tty=True, stream=True, demux=True,) |
| 202 | + stderr_log, stdout_log = '', '' |
| 203 | + try: |
| 204 | + for (stdout_out, stderr_out) in exec_output: |
| 205 | + if stdout_out is not None: |
| 206 | + stdout_log += stdout_out.decode('utf-8', errors='ignore').strip() |
| 207 | + if log: |
| 208 | + ConanOutput().status(stdout_out.decode('utf-8', errors='ignore').strip()) |
| 209 | + if stderr_out is not None: |
| 210 | + stderr_log += stderr_out.decode('utf-8', errors='ignore').strip() |
| 211 | + if log: |
| 212 | + ConanOutput().status(stderr_out.decode('utf-8', errors='ignore').strip()) |
| 213 | + except Exception as e: |
| 214 | + if platform.system() == 'Windows': |
| 215 | + import pywintypes |
| 216 | + if isinstance(e, pywintypes.error): |
| 217 | + pass |
| 218 | + else: |
| 219 | + raise e |
| 220 | + exit_metadata = self.docker_api.exec_inspect(exec_instance['Id']) |
| 221 | + if exit_metadata['Running'] or exit_metadata['ExitCode'] > 0: |
| 222 | + raise RunnerException(command=command, stdout_log=stdout_log, stderr_log=stderr_log) |
| 223 | + return stdout_log, stderr_log |
| 224 | + |
| 225 | + def create_runner_environment(self): |
| 226 | + shutil.rmtree(self.abs_runner_home_path, ignore_errors=True) |
| 227 | + volumes = {self.abs_host_path: {'bind': self.abs_docker_path, 'mode': 'rw'}} |
| 228 | + environment = {'CONAN_RUNNER_ENVIRONMENT': '1'} |
| 229 | + if self.cache == 'shared': |
| 230 | + volumes[ConfigAPI(self.conan_api).home()] = {'bind': '/root/.conan2', 'mode': 'rw'} |
| 231 | + if self.cache in ['clean', 'copy']: |
| 232 | + os.mkdir(self.abs_runner_home_path) |
| 233 | + os.mkdir(os.path.join(self.abs_runner_home_path, 'profiles')) |
| 234 | + |
| 235 | + # Copy all conan config files to docker workspace |
| 236 | + for file_name in ['global.conf', 'settings.yml', 'remotes.json']: |
| 237 | + src_file = os.path.join(ConfigAPI(self.conan_api).home(), file_name) |
| 238 | + if os.path.exists(src_file): |
| 239 | + shutil.copy(src_file, os.path.join(self.abs_runner_home_path, file_name)) |
| 240 | + |
| 241 | + # Copy all profiles to docker workspace |
| 242 | + for current_path, new_path in self.profiles: |
| 243 | + shutil.copy(current_path, new_path) |
| 244 | + |
| 245 | + if self.cache == 'copy': |
| 246 | + tgz_path = os.path.join(self.abs_runner_home_path, 'local_cache_save.tgz') |
| 247 | + _docker_info(f'Save host cache in: {tgz_path}') |
| 248 | + self.conan_api.cache.save(self.conan_api.list.select(ListPattern("*:*")), tgz_path) |
| 249 | + return volumes, environment |
| 250 | + |
| 251 | + def init_container(self): |
| 252 | + min_conan_version = '2.1' |
| 253 | + stdout, _ = self.run_command('conan --version', log=True) |
| 254 | + docker_conan_version = str(stdout.split('Conan version ')[1].replace('\n', '').replace('\r', '')) # Remove all characters and color |
| 255 | + if Version(docker_conan_version) <= Version(min_conan_version): |
| 256 | + ConanOutput().status(f'ERROR: conan version inside the container must be greater than {min_conan_version}', fg=Color.BRIGHT_RED) |
| 257 | + raise ConanException( f'conan version inside the container must be greater than {min_conan_version}') |
| 258 | + if self.cache != 'shared': |
| 259 | + self.run_command('mkdir -p ${HOME}/.conan2/profiles', log=False) |
| 260 | + self.run_command('cp -r "'+self.abs_docker_path+'/.conanrunner/profiles/." ${HOME}/.conan2/profiles/.', log=False) |
| 261 | + for file_name in ['global.conf', 'settings.yml', 'remotes.json']: |
| 262 | + if os.path.exists( os.path.join(self.abs_runner_home_path, file_name)): |
| 263 | + self.run_command('cp "'+self.abs_docker_path+'/.conanrunner/'+file_name+'" ${HOME}/.conan2/'+file_name, log=False) |
| 264 | + if self.cache in ['copy']: |
| 265 | + self.run_command('conan cache restore "'+self.abs_docker_path+'/.conanrunner/local_cache_save.tgz"') |
| 266 | + |
| 267 | + def update_local_cache(self): |
| 268 | + if self.cache != 'shared': |
| 269 | + self.run_command('conan list --graph=create.json --graph-binaries=build --format=json > pkglist.json', log=False) |
| 270 | + self.run_command('conan cache save --list=pkglist.json --file "'+self.abs_docker_path+'"/.conanrunner/docker_cache_save.tgz') |
| 271 | + tgz_path = os.path.join(self.abs_runner_home_path, 'docker_cache_save.tgz') |
| 272 | + _docker_info(f'Restore host cache from: {tgz_path}') |
| 273 | + package_list = self.conan_api.cache.restore(tgz_path) |
0 commit comments