-
Notifications
You must be signed in to change notification settings - Fork 990
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
conan create remote WIP #15856
conan create remote WIP #15856
Changes from 30 commits
7073730
d852ca8
acc000c
284e62e
9175aa6
03d53ca
559428b
62de035
c565352
ea4f495
b7072b9
de279ec
f8c7cc1
aa9dd9a
c913570
9f5b600
001014e
cbf0cf1
e169fd9
7fa7de4
be0fcb0
f0f7d6f
700176e
5e9d533
847c663
7fda3b5
9a9e298
84f6603
8fecd97
07232cd
8056359
6a44fbf
f151c3b
a54ecca
6e26f78
a6e1799
fa8ab58
2977c64
fb6bab8
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,6 @@ | ||
class RunnerException(Exception): | ||
franramirez688 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
def __init__(self, *args, **kwargs): | ||
self.command = kwargs.pop("command", None) | ||
self.stdout_log = kwargs.pop("stdout_log", None) | ||
self.stderr_log = kwargs.pop("stderr_log", None) | ||
super(RunnerException, self).__init__(*args, **kwargs) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,296 @@ | ||
from collections import namedtuple | ||
import os | ||
import json | ||
import platform | ||
import shutil | ||
import yaml | ||
from conan.api.model import ListPattern | ||
from conan.api.output import Color, ConanOutput | ||
from conan.api.conan_api import ConfigAPI | ||
from conan.cli import make_abs_path | ||
from conan.internal.runner import RunnerException | ||
from conans.client.profile_loader import ProfileLoader | ||
from conans.errors import ConanException | ||
from conans.model.version import Version | ||
|
||
|
||
def config_parser(file_path): | ||
''' | ||
- image (str) | ||
run: | ||
- name -> name (str) | ||
- containerEnv -> environment (dict) | ||
- containerUser -> user (str or int) | ||
- privileged -> privileged (boolean) | ||
- capAdd -> cap_add (list) | ||
- securityOpt -> security_opt (list) | ||
- mounts -> volumes (dict) | ||
|
||
dockerfile: | ||
- build.dockerfile -> dockerfile (str) -> dockerfile.read() | ||
- build.context -> path (str) | ||
- build.args -> buildargs (dict) | ||
build.options -> X (python extra params [extra_hosts, network_mode, ...]) | ||
- build.cacheFrom -> cache_from (list) | ||
''' | ||
Build = namedtuple('Build', ['dockerfile', 'build_context', 'build_args', 'cache_from']) | ||
Run = namedtuple('Run', ['name', 'environment', 'user', 'privileged', 'cap_add', 'security_opt', 'volumes']) | ||
Conf = namedtuple('Conf', ['image', 'build', 'run']) | ||
if file_path: | ||
def _instans_or_error(value, obj): | ||
if value and (not isinstance(value, obj)): | ||
raise Exception(f"{value} must be a {obj}") | ||
davidsanfal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
return value | ||
with open(file_path, 'r') as f: | ||
runnerfile = yaml.safe_load(f) | ||
return Conf( | ||
image=_instans_or_error(runnerfile.get('image'), str), | ||
build=Build( | ||
dockerfile=_instans_or_error(runnerfile.get('build', {}).get('dockerfile'), str), | ||
build_context=_instans_or_error(runnerfile.get('build', {}).get('build_context'), str), | ||
build_args=_instans_or_error(runnerfile.get('build', {}).get('build_args'), dict), | ||
cache_from=_instans_or_error(runnerfile.get('build', {}).get('cacheFrom'), list), | ||
), | ||
run=Run( | ||
name=_instans_or_error(runnerfile.get('run', {}).get('name'), str), | ||
environment=_instans_or_error(runnerfile.get('run', {}).get('containerEnv'), dict), | ||
user=_instans_or_error(runnerfile.get('run', {}).get('containerUser'), str), | ||
privileged=_instans_or_error(runnerfile.get('run', {}).get('privileged'), bool), | ||
cap_add=_instans_or_error(runnerfile.get('run', {}).get('capAdd'), list), | ||
security_opt=_instans_or_error(runnerfile.get('run', {}).get('securityOpt'), dict), | ||
volumes=_instans_or_error(runnerfile.get('run', {}).get('mounts'), dict), | ||
) | ||
) | ||
else: | ||
return Conf( | ||
image=None, | ||
build=Build(dockerfile=None, build_context=None, build_args=None, cache_from=None), | ||
run=Run(name=None, environment=None, user=None, privileged=None, cap_add=None, security_opt=None, volumes=None) | ||
) | ||
|
||
|
||
def docker_info(msg, error=False): | ||
fg=Color.BRIGHT_MAGENTA | ||
if error: | ||
fg=Color.BRIGHT_RED | ||
ConanOutput().status('\n┌'+'─'*(2+len(msg))+'┐', fg=fg) | ||
ConanOutput().status(f'| {msg} |', fg=fg) | ||
ConanOutput().status('└'+'─'*(2+len(msg))+'┘\n', fg=fg) | ||
|
||
|
||
def list_patterns(cache_info): | ||
davidsanfal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
_pattern = [] | ||
for reference, info in cache_info.items(): | ||
for revisions in info.get('revisions', {}).values(): | ||
for package in revisions.get('packages').keys(): | ||
_pattern.append(f'{reference}:{package}') | ||
return _pattern | ||
|
||
|
||
class DockerRunner: | ||
def __init__(self, conan_api, command, host_profile, build_profile, args, raw_args): | ||
import docker | ||
import docker.api.build | ||
try: | ||
self.docker_client = docker.from_env() | ||
self.docker_api = docker.APIClient() | ||
docker.api.build.process_dockerfile = lambda dockerfile, path: ('Dockerfile', dockerfile) | ||
except: | ||
raise ConanException("Docker Client failed to initialize." | ||
"\n - Check if docker is installed and running" | ||
"\n - Run 'pip install docker>=5.0.0, <=5.0.3'") | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Better |
||
self.conan_api = conan_api | ||
self.build_profile = build_profile | ||
self.args = args | ||
self.abs_host_path = make_abs_path(args.path) | ||
if args.format: | ||
raise ConanException("format argument is forbidden if running in a docker runner") | ||
|
||
# Runner config | ||
self.abs_runner_home_path = os.path.join(self.abs_host_path, '.conanrunner') | ||
self.abs_docker_path = os.path.join('/root/conanrunner', os.path.basename(self.abs_host_path)).replace("\\","/") | ||
|
||
# Update conan command and some paths to run inside the container | ||
raw_args[raw_args.index(args.path)] = self.abs_docker_path | ||
self.profiles = [] | ||
profile_list = set(self.args.profile_build + self.args.profile_host) | ||
davidsanfal marked this conversation as resolved.
Show resolved
Hide resolved
|
||
# Update the profile paths | ||
for i, raw_arg in enumerate(raw_args): | ||
for i, raw_profile in enumerate(profile_list): | ||
_profile = ProfileLoader.get_profile_path(os.path.join(ConfigAPI(self.conan_api).home(), 'profiles'), raw_profile, os.getcwd()) | ||
_name = f'{os.path.basename(_profile)}_{i}' | ||
if raw_profile in raw_arg: | ||
raw_args[raw_args.index(raw_arg)] = raw_arg.replace(raw_profile, os.path.join(self.abs_docker_path, '.conanrunner/profiles', _name)) | ||
self.profiles.append([_profile, os.path.join(self.abs_runner_home_path, 'profiles', _name)]) | ||
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']) | ||
|
||
# Container config | ||
# https://containers.dev/implementors/json_reference/ | ||
self.configfile = config_parser(host_profile.runner.get('configfile')) | ||
self.dockerfile = host_profile.runner.get('dockerfile') or self.configfile.build.dockerfile | ||
self.docker_build_context = host_profile.runner.get('build_context') or self.configfile.build.build_context | ||
self.image = host_profile.runner.get('image') or self.configfile.image | ||
Comment on lines
+108
to
+110
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I find the preference order in selecting from the profile or the config file a bit unnatural, because you can set in the profile contradictory things, like for example using a configfile that points to a dockerfile and be pointing in the profile to another one, shouldn't the configfile and the profile settings that overlap be incompatible in some way? |
||
if not (self.dockerfile or self.image): | ||
raise ConanException("'dockerfile' or docker image name is needed") | ||
self.image = self.image or 'conan-runner-default' | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Maybe not for this PR, but suggesting here that we make the default image name configurable with something like |
||
self.name = self.configfile.image or f'conan-runner-{host_profile.runner.get("suffix", "docker")}' | ||
self.remove = str(host_profile.runner.get('remove', 'false')).lower() == 'true' | ||
self.cache = str(host_profile.runner.get('cache', 'clean')) | ||
self.container = None | ||
|
||
def run(self): | ||
""" | ||
run conan inside a Docker continer | ||
""" | ||
if self.dockerfile: | ||
docker_info(f'Building the Docker image: {self.image}') | ||
self.build_image() | ||
volumes, environment = self.create_runner_environment() | ||
error = False | ||
try: | ||
if self.docker_client.containers.list(all=True, filters={'name': self.name}): | ||
docker_info('Starting the docker container') | ||
self.container = self.docker_client.containers.get(self.name) | ||
self.container.start() | ||
else: | ||
if self.configfile.run.environment: | ||
environment.update(self.configfile.run.environment) | ||
if self.configfile.run.volumes: | ||
volumes.update(self.configfile.run.volumes) | ||
docker_info('Creating the docker container') | ||
self.container = self.docker_client.containers.run( | ||
self.image, | ||
"/bin/bash -c 'while true; do sleep 30; done;'", | ||
name=self.name, | ||
volumes=volumes, | ||
environment=environment, | ||
user=self.configfile.run.user, | ||
privileged=self.configfile.run.privileged, | ||
cap_add=self.configfile.run.cap_add, | ||
security_opt=self.configfile.run.security_opt, | ||
detach=True, | ||
auto_remove=False) | ||
docker_info(f'Container {self.name} running') | ||
except Exception as e: | ||
raise ConanException(f'Imposible to run the container "{self.name}" with image "{self.image}"' | ||
f'\n\n{str(e)}') | ||
try: | ||
self.init_container() | ||
self.run_command(self.command) | ||
self.update_local_cache() | ||
except ConanException as e: | ||
error = True | ||
raise e | ||
except RunnerException as e: | ||
error = True | ||
raise ConanException(f'"{e.command}" inside docker fail' | ||
f'\n\nLast command output: {str(e.stdout_log)}') | ||
finally: | ||
if self.container: | ||
error_prefix = 'ERROR: ' if error else '' | ||
docker_info(f'{error_prefix}Stopping container', error) | ||
self.container.stop() | ||
if self.remove: | ||
docker_info(f'{error_prefix}Removing container', error) | ||
self.container.remove() | ||
|
||
def build_image(self): | ||
dockerfile_file_path = self.dockerfile | ||
if os.path.isdir(self.dockerfile): | ||
dockerfile_file_path = os.path.join(self.dockerfile, 'Dockerfile') | ||
with open(dockerfile_file_path) as f: | ||
build_path = self.docker_build_context or os.path.dirname(dockerfile_file_path) | ||
ConanOutput().highlight(f"Dockerfile path: '{dockerfile_file_path}'") | ||
ConanOutput().highlight(f"Docker build context: '{build_path}'\n") | ||
docker_build_logs = self.docker_api.build( | ||
path=build_path, | ||
dockerfile=f.read(), | ||
tag=self.image, | ||
buildargs=self.configfile.build.build_args, | ||
cache_from=self.configfile.build.cache_from, | ||
) | ||
for chunk in docker_build_logs: | ||
for line in chunk.decode("utf-8").split('\r\n'): | ||
if line: | ||
stream = json.loads(line).get('stream') | ||
if stream: | ||
ConanOutput().status(stream.strip()) | ||
|
||
def run_command(self, command, log=True): | ||
if log: | ||
docker_info(f'Running in container: "{command}"') | ||
exec_instance = self.docker_api.exec_create(self.container.id, f"/bin/bash -c '{command}'", tty=True) | ||
exec_output = self.docker_api.exec_start(exec_instance['Id'], tty=True, stream=True, demux=True,) | ||
stderr_log, stdout_log = '', '' | ||
try: | ||
for (stdout_out, stderr_out) in exec_output: | ||
if stdout_out is not None: | ||
stdout_log += stdout_out.decode('utf-8', errors='ignore').strip() | ||
if log: | ||
ConanOutput().status(stdout_out.decode('utf-8', errors='ignore').strip()) | ||
if stderr_out is not None: | ||
stderr_log += stderr_out.decode('utf-8', errors='ignore').strip() | ||
if log: | ||
ConanOutput().status(stderr_out.decode('utf-8', errors='ignore').strip()) | ||
except Exception as e: | ||
if platform.system() == 'Windows': | ||
import pywintypes | ||
if isinstance(e, pywintypes.error): | ||
pass | ||
else: | ||
raise e | ||
exit_metadata = self.docker_api.exec_inspect(exec_instance['Id']) | ||
if exit_metadata['Running'] or exit_metadata['ExitCode'] > 0: | ||
raise RunnerException(command=command, stdout_log=stdout_log, stderr_log=stderr_log) | ||
return stdout_log, stderr_log | ||
|
||
def create_runner_environment(self): | ||
shutil.rmtree(self.abs_runner_home_path, ignore_errors=True) | ||
volumes = {self.abs_host_path: {'bind': self.abs_docker_path, 'mode': 'rw'}} | ||
environment = {'CONAN_RUNNER_ENVIRONMENT': '1'} | ||
if self.cache == 'shared': | ||
volumes[ConfigAPI(self.conan_api).home()] = {'bind': '/root/.conan2', 'mode': 'rw'} | ||
if self.cache in ['clean', 'copy']: | ||
os.mkdir(self.abs_runner_home_path) | ||
os.mkdir(os.path.join(self.abs_runner_home_path, 'profiles')) | ||
|
||
# Copy all conan config files to docker workspace | ||
for file_name in ['global.conf', 'settings.yml', 'remotes.json']: | ||
src_file = os.path.join(ConfigAPI(self.conan_api).home(), file_name) | ||
if os.path.exists(src_file): | ||
shutil.copy(src_file, os.path.join(self.abs_runner_home_path, file_name)) | ||
|
||
# Copy all profiles to docker workspace | ||
for current_path, new_path in self.profiles: | ||
shutil.copy(current_path, new_path) | ||
|
||
if self.cache == 'copy': | ||
tgz_path = os.path.join(self.abs_runner_home_path, 'local_cache_save.tgz') | ||
docker_info(f'Save host cache in: {tgz_path}') | ||
self.conan_api.cache.save(self.conan_api.list.select(ListPattern("*:*")), tgz_path) | ||
return volumes, environment | ||
|
||
def init_container(self): | ||
min_conan_version = '2.1' | ||
stdout, _ = self.run_command('conan --version', log=True) | ||
docker_conan_version = str(stdout.split('Conan version ')[1].replace('\n', '').replace('\r', '')) # Remove all characters and color | ||
print(docker_conan_version) | ||
if Version(docker_conan_version) <= Version(min_conan_version): | ||
ConanOutput().status(f'ERROR: conan version inside the container must be greater than {min_conan_version}', fg=Color.BRIGHT_RED) | ||
raise ConanException( f'conan version inside the container must be greater than {min_conan_version}') | ||
if self.cache != 'shared': | ||
self.run_command('mkdir -p ${HOME}/.conan2/profiles', log=False) | ||
self.run_command('cp -r "'+self.abs_docker_path+'/.conanrunner/profiles/." ${HOME}/.conan2/profiles/.', log=False) | ||
for file_name in ['global.conf', 'settings.yml', 'remotes.json']: | ||
if os.path.exists( os.path.join(self.abs_runner_home_path, file_name)): | ||
self.run_command('cp "'+self.abs_docker_path+'/.conanrunner/'+file_name+'" ${HOME}/.conan2/'+file_name, log=False) | ||
if self.cache in ['copy']: | ||
self.run_command('conan cache restore "'+self.abs_docker_path+'/.conanrunner/local_cache_save.tgz"') | ||
|
||
def update_local_cache(self): | ||
if self.cache != 'shared': | ||
self.run_command('conan list --graph=create.json --graph-binaries=build --format=json > pkglist.json', log=False) | ||
self.run_command('conan cache save --list=pkglist.json --file "'+self.abs_docker_path+'"/.conanrunner/docker_cache_save.tgz') | ||
tgz_path = os.path.join(self.abs_runner_home_path, 'docker_cache_save.tgz') | ||
docker_info(f'Restore host cache from: {tgz_path}') | ||
package_list = self.conan_api.cache.restore(tgz_path) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
A bit too much, I wouldn't mind something in 3 separate lines, and also something that raise a controlled ConanException if the
runner.type
is not defined or incorrect.