Repository: conan-io/conan
Pull request #17542 (2024-12-31 09:05:24)

Refactor docker runner: new logging system and fixed several bugs
Changelog: Feature: Fix several bugs in docker runner, added new configuration options and improved logging system
Docs: https://github.com/conan-io/docs/pull/3977

- Homogeince logs with the the rest of the runners
- Add new configuration options such as defining docker `platform` from configfile and directly from profile -> this allows defining platform such as `linux/amd64` on Mac Silicon systems, avoiding compilation issues related with architecture
- Rearrange and improve the readability of code. 
- Fix minor bugs while reading profiles
- Renamed profile options to have `docker.` prefix to avoid collisions with other runner configurations
- Fix https://github.com/conan-io/conan/issues/17746

---
- [x] Refer to the issue that supports this Pull Request.
- [x] If the issue has missing info, explain the purpose/use case/pain/need that covers this Pull Request.
- [x] I've read the [Contributing guide](https://github.com/conan-io/conan/blob/develop2/.github/CONTRIBUTING.md).
- [x] I've followed the PEP8 style guides for Python code.
- [x] I've opened another PR in the Conan docs repo to the ``develop`` branch, documenting this one.



Patch:
diff --git a/conan/api/output.py b/conan/api/output.py
index 25fa62698..6989abc07 100644
--- a/conan/api/output.py
+++ b/conan/api/output.py
@@ -175,7 +175,7 @@ class ConanOutput:
 
         return self
 
-    def _write_message(self, msg, fg=None, bg=None):
+    def _write_message(self, msg, fg=None, bg=None, newline=True):
         if isinstance(msg, dict):
             # For traces we can receive a dict already, we try to transform then into more natural
             # text
@@ -195,8 +195,11 @@ class ConanOutput:
         else:
             ret += "{}".format(msg)
 
+        if newline:
+            ret = "%s\n" % ret
+
         with self.lock:
-            self.stream.write("{}\n".format(ret))
+            self.stream.write(ret)
             self.stream.flush()
 
     def trace(self, msg):
diff --git a/conan/internal/runner/docker.py b/conan/internal/runner/docker.py
index c21bee192..b6d24f832 100644
--- a/conan/internal/runner/docker.py
+++ b/conan/internal/runner/docker.py
@@ -1,37 +1,68 @@
-from collections import namedtuple
+from argparse import Namespace
 import os
+import sys
 import json
 import platform
 import shutil
+from typing import Optional, NamedTuple, Dict, List
 import yaml
+from conan.api.conan_api import ConanAPI
 from conan.api.model import ListPattern
 from conan.api.output import Color, ConanOutput
 from conan.cli import make_abs_path
 from conan.internal.runner import RunnerException
 from conan.errors import ConanException
+from pathlib import Path
+from conan.internal.model.profile import Profile
 from conan.internal.model.version import Version
+from conan.internal.runner.output import RunnerOutput
+from conan.internal.conan_app import ConanApp
 
+class _ContainerConfig(NamedTuple):
+    class Build(NamedTuple):
+        dockerfile: Optional[str] = None
+        build_context: Optional[str] = None
+        build_args: Optional[Dict[str, str]] = None
+        cache_from: Optional[List[str]] = None
+        platform: Optional[str] = None
+
+    class Run(NamedTuple):
+        name: Optional[str] = None
+        environment: Optional[Dict[str, str]] = None
+        user: Optional[str] = None
+        privileged: Optional[bool] = None
+        cap_add: Optional[List[str]] = None
+        security_opt: Optional[List[str]] = None
+        volumes: Optional[Dict[str, str]] = None
+        network: Optional[str] = None
+
+    image: Optional[str] = None
+    build: Build = Build()
+    run: Run = Run()
+
+    @staticmethod
+    def load(file_path: Optional[str]) -> '_ContainerConfig':
+        # Container config
+        # https://containers.dev/implementors/json_reference/
+        if not file_path:
+            return _ContainerConfig()
 
-def config_parser(file_path):
-    Build = namedtuple('Build', ['dockerfile', 'build_context', 'build_args', 'cache_from'])
-    Run = namedtuple('Run', ['name', 'environment', 'user', 'privileged', 'cap_add', 'security_opt', 'volumes', 'network'])
-    Conf = namedtuple('Conf', ['image', 'build', 'run'])
-    if file_path:
         def _instans_or_error(value, obj):
             if value and (not isinstance(value, obj)):
                 raise ConanException(f"docker runner configfile syntax error: {value} must be a {obj.__name__}")
             return value
         with open(file_path, 'r') as f:
             runnerfile = yaml.safe_load(f)
-        return Conf(
+        return _ContainerConfig(
             image=_instans_or_error(runnerfile.get('image'), str),
-            build=Build(
+            build=_ContainerConfig.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),
+                platform=_instans_or_error(runnerfile.get('build', {}).get('platform'), str),
             ),
-            run=Run(
+            run=_ContainerConfig.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),
@@ -42,90 +73,120 @@ def config_parser(file_path):
                 network=_instans_or_error(runnerfile.get('run', {}).get('network'), str),
             )
         )
-    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, network=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)
-
 
 class DockerRunner:
-    def __init__(self, conan_api, command, host_profile, build_profile, args, raw_args):
-        import docker.api.build
-        try:
-            docker_base_urls = [
-                None, # Default docker configuration, let the python library detect the socket
-                os.environ.get('DOCKER_HOST'), # Connect to socket defined in DOCKER_HOST
-                'unix:///var/run/docker.sock', # Default linux socket
-                f'unix://{os.path.expanduser("~")}/.rd/docker.sock' # Rancher linux socket
-            ]
-            for base_url in docker_base_urls:
-                try:
-                    ConanOutput().verbose(f'Trying to connect to docker "{base_url or "default"}" socket')
-                    self.docker_client = docker.DockerClient(base_url=base_url, version='auto')
-                    ConanOutput().verbose(f'Connected to docker "{base_url or "default"}" socket')
-                    break
-                except:
-                    continue
-            self.docker_api = self.docker_client.api
-            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 conan[runners]'")
+    def __init__(self, conan_api: ConanAPI, command: str, host_profile: Profile, build_profile: Profile, args: Namespace, raw_args: list[str]):
+        self.logger = ConanOutput()
+        self.docker_client = self._initialize_docker_client()
+        self.docker_api = self.docker_client.api
         self.conan_api = conan_api
         self.build_profile = build_profile
+        self.abs_host_path = self._get_abs_host_path(args.path)
         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")
 
-        # Container config
-        # https://containers.dev/implementors/json_reference/
-        self.configfile = config_parser(host_profile.runner.get('configfile'))
+        self.configfile = _ContainerConfig.load(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.platform = host_profile.runner.get('platform') or self.configfile.build.platform
         self.image = host_profile.runner.get('image') or self.configfile.image
         if not (self.dockerfile or self.image):
             raise ConanException("'dockerfile' or docker image name is needed")
         self.image = self.image or 'conan-runner-default'
-        self.name = self.configfile.run.name or f'conan-runner-{host_profile.runner.get("suffix", "docker")}'
+        self.name = self.configfile.run.name or host_profile.runner.get("name", "conan-runner-docker")
         self.remove = str(host_profile.runner.get('remove', 'false')).lower() == 'true'
         self.cache = str(host_profile.runner.get('cache', 'clean'))
+        if self.cache not in ['clean', 'copy', 'shared']:
+            raise ConanException(f'Invalid cache value: "{self.cache}". Valid values are: clean, copy, shared')
         self.container = None
+        self.raw_args = raw_args
+        self.command = command
+        self.runner_logger = RunnerOutput(self.name)
 
-        # Runner config>
-        self.abs_runner_home_path = os.path.join(self.abs_host_path, '.conanrunner')
-        self.docker_user_name = self.configfile.run.user or 'root'
-        self.abs_docker_path = os.path.join(f'/{self.docker_user_name}/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.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'])
-
-    def run(self):
+    def run(self) -> None:
         """
-        run conan inside a Docker continer
+        Run conan inside a Docker container
         """
-        if self.dockerfile:
-            _docker_info(f'Building the Docker image: {self.image}')
-            self.build_image()
-        volumes, environment = self.create_runner_environment()
-        error = False
+        self._build_image()
+        self._start_container()
+        try:
+            self._init_container()
+            self._run_command(self.command)
+            self._update_local_cache()
+        except RunnerException as e:
+            raise ConanException(f'"{e.command}" inside docker fail')
+        finally:
+            if self.container:
+                error = sys.exc_info()[0] is not None # Check if error has been raised
+                log = self.logger.error if error else self.logger.status
+                log('Stopping container')
+                self.container.stop()
+                if self.remove:
+                    log('Removing container')
+                    self.container.remove()
+
+    def _initialize_docker_client(self):
+        import docker
+        import docker.api.build
+
+        docker_base_urls = [
+            None,  # Default docker configuration
+            os.environ.get('DOCKER_HOST'),  # From DOCKER_HOST environment variable
+            'unix:///var/run/docker.sock',  # Default Linux socket
+            f'unix://{os.path.expanduser("~")}/.rd/docker.sock'  # Rancher Linux socket
+        ]
+
+        for base_url in docker_base_urls:
+            try:
+                self.logger.verbose(f'Trying to connect to Docker: "{base_url or "default"}"')
+                client = docker.DockerClient(base_url=base_url, version='auto')
+                self.logger.verbose(f'Connected to Docker: "{base_url or "default"}"')
+                docker.api.build.process_dockerfile = lambda dockerfile, path: ('Dockerfile', dockerfile)
+                return client
+            except Exception:
+                continue
+        raise ConanException("Docker Client failed to initialize."
+                             "\n - Check if docker is installed and running"
+                             "\n - Run 'pip install conan[runners]'")
+
+    def _get_abs_host_path(self, path: str) -> Path:
+        abs_path = make_abs_path(path)
+        if abs_path is None:
+            raise ConanException("Could not determine the absolute path.")
+        return Path(abs_path)
+
+    def _build_image(self) -> None:
+        if not self.dockerfile:
+            return
+        self.logger.status(f'Building the Docker image: {self.image}')
+        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)
+            self.logger.highlight(f"Dockerfile path: '{dockerfile_file_path}'")
+            self.logger.highlight(f"Docker build context: '{build_path}'\n")
+            docker_build_logs = self.docker_api.build(
+                path=build_path,
+                dockerfile=f.read(),
+                tag=self.image,
+                platform=self.platform,
+                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:
+                        self.logger.status(stream.strip())
+
+    def _start_container(self) -> None:
+        volumes, environment = self._create_runner_environment()
         try:
             if self.docker_client.containers.list(all=True, filters={'name': self.name}):
-                _docker_info('Starting the docker container')
+                self.logger.status('Starting the docker container', fg=Color.BRIGHT_MAGENTA)
                 self.container = self.docker_client.containers.get(self.name)
                 self.container.start()
             else:
@@ -133,7 +194,7 @@ class DockerRunner:
                     environment.update(self.configfile.run.environment)
                 if self.configfile.run.volumes:
                     volumes.update(self.configfile.run.volumes)
-                _docker_info('Creating the docker container')
+                self.logger.status('Creating the docker container', fg=Color.BRIGHT_MAGENTA)
                 self.container = self.docker_client.containers.run(
                     self.image,
                     "/bin/bash -c 'while true; do sleep 30; done;'",
@@ -147,69 +208,28 @@ class DockerRunner:
                     detach=True,
                     auto_remove=False,
                     network=self.configfile.run.network)
-            _docker_info(f'Container {self.name} running')
+            self.logger.status(f'Container {self.name} running', fg=Color.BRIGHT_MAGENTA)
         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, workdir=None, log=True):
+    def _run_command(self, command: str, workdir: Optional[str] = None, verbose: bool = True) -> tuple[str, str]:
         workdir = workdir or self.abs_docker_path
-        if log:
-            _docker_info(f'Running in container: "{command}"')
+        log = self.runner_logger.status if verbose else self.runner_logger.verbose
+        log(f'$ {command}', fg=Color.BLUE)
         exec_instance = self.docker_api.exec_create(self.container.id, f"/bin/bash -c '{command}'", workdir=workdir, 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())
+                    decoded = stdout_out.decode('utf-8', errors='ignore').strip()
+                    stdout_log += decoded
+                    log(decoded)
                 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())
+                    decoded = stderr_out.decode('utf-8', errors='ignore').strip()
+                    stderr_log += decoded
+                    log(decoded)
         except Exception as e:
             if platform.system() == 'Windows':
                 import pywintypes
@@ -222,52 +242,92 @@ class DockerRunner:
             raise RunnerException(command=command, stdout_log=stdout_log, stderr_log=stderr_log)
         return stdout_log, stderr_log
 
-    def create_runner_environment(self):
+    def _get_volumes_and_docker_path(self) -> tuple[dict,str]:
+        app = ConanApp(self.conan_api)
+        remotes = self.conan_api.remotes.list(self.args.remote) if not self.args.no_remote else []
+        conanfile = app.loader.load_consumer(self.abs_host_path / "conanfile.py", remotes=remotes)
+        abs_docker_base_path = Path('/') / self.docker_user_name / 'conanrunner'
+        # Check if recipe has defined a root folder
+        # In this case, mount the root folder as the base path and update the abs_docker_path to the
+        # new relative path
+        if hasattr(conanfile, "layout"):
+            try:
+                conanfile.layout()
+                if conanfile.folders.root:
+                    abs_path = self._get_abs_host_path(conanfile.folders.root)
+                    if self.abs_host_path.is_relative_to(abs_path):
+                        abs_docker_base_path /= abs_path.name
+                        volumes = {abs_path: {'bind': abs_docker_base_path.as_posix(), 'mode': 'rw'}}
+                        abs_docker_path = abs_docker_base_path / self.abs_host_path.relative_to(abs_path)
+                        return volumes, abs_docker_path.as_posix()
+            except:
+                pass
+        abs_docker_path = (abs_docker_base_path / self.abs_host_path.name).as_posix()
+        volumes = {self.abs_host_path: {'bind': abs_docker_path, 'mode': 'rw'}}
+        return volumes, abs_docker_path
+
+    def _create_runner_environment(self) -> tuple[dict, dict]:
+        # Runner configuration
+        self.abs_runner_home_path = self.abs_host_path / '.conanrunner'
+        self.docker_user_name = self.configfile.run.user or 'root'
+        volumes, self.abs_docker_path = self._get_volumes_and_docker_path()
         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'}
 
+        # Update conan command and some paths to run inside the container
+        self.raw_args[self.raw_args.index(self.args.path)] = self.abs_docker_path
+        self.command = ' '.join([f'conan {self.command}'] + [f'"{raw_arg}"' if ' ' in raw_arg else raw_arg for raw_arg in self.raw_args] + ['-f json > create.json'])
+
         if self.cache == 'shared':
             volumes[self.conan_api.home_folder] = {'bind': f'/{self.docker_user_name}/.conan2', 'mode': 'rw'}
 
         if self.cache in ['clean', 'copy']:
             # Copy all conan profiles and config files to docker workspace
-            os.mkdir(self.abs_runner_home_path)
-            shutil.copytree(
-                os.path.join(self.conan_api.home_folder, 'profiles'),
-                os.path.join(self.abs_runner_home_path, 'profiles')
-            )
+            for profile in set(self.args.profile_host + (self.args.profile_build or [])):
+                profile_path = self.conan_api.profiles.get_path(profile)
+                dest_filename = self.abs_runner_home_path / 'profiles' / Path(profile)
+                if not dest_filename.exists():
+                    dest_filename.parent.mkdir(parents=True, exist_ok=True)
+                self.logger.verbose(f"Copying profile '{profile}': {profile_path} -> {dest_filename}")
+                shutil.copy(profile_path, dest_filename)
+            if not self.args.profile_build:
+                dest_filename = self.abs_runner_home_path / 'profiles' / "default"
+                default_build_profile = self.conan_api.profiles.get_default_build()
+                self.logger.verbose(f"Copying default profile: {default_build_profile} -> {dest_filename}")
+                shutil.copy(default_build_profile, dest_filename.as_posix())
+
             for file_name in ['global.conf', 'settings.yml', 'remotes.json']:
-                src_file = os.path.join(self.conan_api.home_folder, file_name)
-                if os.path.exists(src_file):
-                    shutil.copy(src_file, os.path.join(self.abs_runner_home_path, file_name))
+                src_file = Path(self.conan_api.home_folder) / file_name
+                if src_file.exists():
+                    self.logger.verbose(f"Copying {src_file} -> {self.abs_runner_home_path / file_name}")
+                    shutil.copy(src_file, self.abs_runner_home_path / file_name)
 
             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}')
+                tgz_path = self.abs_runner_home_path / 'local_cache_save.tgz'
+                self.logger.status(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):
+    def _init_container(self) -> None:
         min_conan_version = '2.1'
-        stdout, _ = self.run_command('conan --version', log=True)
+        stdout, _ = self._run_command('conan --version', verbose=True)
         docker_conan_version = str(stdout.split('Conan version ')[1].replace('\n', '').replace('\r', '')) # Remove all characters and color
         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}')
+            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)
+            self._run_command('mkdir -p ${HOME}/.conan2/profiles', verbose=False)
+            self._run_command('cp -r "'+self.abs_docker_path+'/.conanrunner/profiles/." ${HOME}/.conan2/profiles/.', verbose=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.abs_runner_home_path / file_name).exists():
+                    self._run_command('cp "'+self.abs_docker_path+'/.conanrunner/'+file_name+'" ${HOME}/.conan2/'+file_name, verbose=False)
             if self.cache in ['copy']:
-                self.run_command('conan cache restore "'+self.abs_docker_path+'/.conanrunner/local_cache_save.tgz"')
+                self._run_command('conan cache restore "'+self.abs_docker_path+'/.conanrunner/local_cache_save.tgz"')
 
-    def update_local_cache(self):
+    def _update_local_cache(self) -> None:
         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)
+            self._run_command('conan list --graph=create.json --graph-binaries=build --format=json > pkglist.json', verbose=False)
+            self._run_command('conan cache save --list=pkglist.json --file "'+self.abs_docker_path+'"/.conanrunner/docker_cache_save.tgz')
+            tgz_path = self.abs_runner_home_path / 'docker_cache_save.tgz'
+            self.logger.status(f'Restore host cache from: {tgz_path}')
+            self.conan_api.cache.restore(tgz_path)
diff --git a/conan/internal/runner/output.py b/conan/internal/runner/output.py
new file mode 100644
index 000000000..a1732aa9e
--- /dev/null
+++ b/conan/internal/runner/output.py
@@ -0,0 +1,12 @@
+from conan.api.output import Color, ConanOutput
+
+class RunnerOutput(ConanOutput):
+    def __init__(self, runner_info: str):
+        super().__init__()
+        self.set_warnings_as_errors(True) # Make log errors blocker
+        self._prefix = f"{runner_info} | "
+
+    def _write_message(self, msg, fg=None, bg=None, newline=True):
+        for line in msg.splitlines():
+            super()._write_message(self._prefix, Color.BLACK, Color.BRIGHT_YELLOW, newline=False)
+            super()._write_message(line, fg, bg, newline)
diff --git a/conan/tools/cmake/cmakedeps2/cmakedeps.py b/conan/tools/cmake/cmakedeps2/cmakedeps.py
index e190c268a..cae3326c0 100644
--- a/conan/tools/cmake/cmakedeps2/cmakedeps.py
+++ b/conan/tools/cmake/cmakedeps2/cmakedeps.py
@@ -61,7 +61,7 @@ class CMakeDeps2:
                 continue
 
             if require.direct:
-                direct_deps.append(dep)
+                direct_deps.append((require, dep))
             config = ConfigTemplate2(self, dep)
             ret[config.filename] = config.content()
             config_version = ConfigVersionTemplate2(self, dep)
@@ -78,14 +78,16 @@ class CMakeDeps2:
     def _print_help(self, direct_deps):
         if direct_deps:
             msg = ["CMakeDeps necessary find_package() and targets for your CMakeLists.txt"]
-            targets = []
-            for dep in direct_deps:
-                msg.append(f"    find_package({self.get_cmake_filename(dep)})")
-                if not dep.cpp_info.exe:
+            link_targets = []
+            for (require, dep) in direct_deps:
+                note = " # Optional. This is a tool-require, can't link its targets" \
+                    if require.build else ""
+                msg.append(f"    find_package({self.get_cmake_filename(dep)}){note}")
+                if not require.build and not dep.cpp_info.exe:
                     target_name = self.get_property("cmake_target_name", dep)
-                    targets.append(target_name or f"{dep.ref.name}::{dep.ref.name}")
-            if targets:
-                msg.append(f"    target_link_libraries(... {' '.join(targets)})")
+                    link_targets.append(target_name or f"{dep.ref.name}::{dep.ref.name}")
+            if link_targets:
+                msg.append(f"    target_link_libraries(... {' '.join(link_targets)})")
             self._conanfile.output.info("\n".join(msg), fg=Color.CYAN)
 
     def set_property(self, dep, prop, value, build_context=False):


Test patch:
diff --git a/test/functional/command/runner_test.py b/test/functional/command/runner_test.py
index 9b1e16d87..5eae54cb5 100644
--- a/test/functional/command/runner_test.py
+++ b/test/functional/command/runner_test.py
@@ -542,9 +542,104 @@ def test_create_docker_runner_default_build_profile():
 
     client.save({"host_clean": profile_host})
     client.run("new cmake_lib -d name=pkg -d version=0.2")
-    client.run("create . -pr:h host_clean")
+    client.run("create . -pr:h host_clean -vverbose")
 
+    assert "Copying default profile" in client.out
     assert "Restore: pkg/0.2" in client.out
     assert "Restore: pkg/0.2:8631cf963dbbb4d7a378a64a6fd1dc57558bc2fe" in client.out
     assert "Restore: pkg/0.2:8631cf963dbbb4d7a378a64a6fd1dc57558bc2fe metadata" in client.out
     assert "Removing container" in client.out
+
+
+@pytest.mark.docker_runner
+@pytest.mark.skipif(docker_skip('ubuntu:22.04'), reason="Only docker running")
+def test_create_docker_runner_in_subfolder():
+    client = TestClient()
+    conanfile = textwrap.dedent("""
+        import os
+        from conan import ConanFile
+        from conan.tools.files import load, copy
+        from conan.tools.cmake import CMake
+
+        class Pkg(ConanFile):
+            name = "pkg"
+            version = "1.0"
+            settings = "os", "compiler", "build_type", "arch"
+            generators = "CMakeToolchain"
+
+            def layout(self):
+                self.folders.root = ".."
+                self.folders.source = "."
+                self.folders.build = "build"
+
+            def export_sources(self):
+                folder = os.path.join(self.recipe_folder, "..")
+                copy(self, "*.txt", folder, self.export_sources_folder)
+                copy(self, "src/*.cpp", folder, self.export_sources_folder)
+                copy(self, "include/*.h", folder, self.export_sources_folder)
+
+            def source(self):
+                cmake_file = load(self, "CMakeLists.txt")
+
+            def build(self):
+                path = os.path.join(self.source_folder, "CMakeLists.txt")
+                cmake_file = load(self, path)
+                cmake = CMake(self)
+                cmake.configure()
+                cmake.build()
+
+            def package(self):
+                cmake = CMake(self)
+                cmake.install()
+            """)
+
+    header = textwrap.dedent("""
+        #pragma once
+        void hello();
+        """)
+    source = textwrap.dedent("""
+        #include <iostream>
+        void hello() {
+            std::cout << "Hello!" << std::endl;
+        }
+        """)
+
+    cmakelist = textwrap.dedent("""
+        cmake_minimum_required(VERSION 3.15)
+        project(pkg CXX)
+        add_library(pkg src/hello.cpp)
+        target_include_directories(pkg PUBLIC include)
+        set_target_properties(pkg PROPERTIES PUBLIC_HEADER "include/hello.h")
+        install(TARGETS pkg)
+
+        """)
+
+    profile_host = textwrap.dedent(f"""\
+        [settings]
+        arch={{{{ detect_api.detect_arch() }}}}
+        build_type=Release
+        compiler=gcc
+        compiler.cppstd=gnu17
+        compiler.libcxx=libstdc++11
+        compiler.version=11
+        os=Linux
+        [runner]
+        type=docker
+        dockerfile={dockerfile_path()}
+        build_context={conan_base_path()}
+        image=conan-runner-default-test
+        cache=clean
+        remove=True
+        """)
+
+    client.save({"conan/conanfile.py": conanfile,
+                "conan/host": profile_host,
+                "include/hello.h": header,
+                "src/hello.cpp": source,
+                "CMakeLists.txt": cmakelist})
+
+    with client.chdir("conan"):
+        client.run("create . -pr:h host -vverbose")
+
+    assert "Restore: pkg/1.0" in client.out
+    assert "Removing container" in client.out
diff --git a/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py b/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py
index 64a993e5e..27a7184cb 100644
--- a/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py
+++ b/test/functional/toolchains/cmake/cmakedeps/test_cmakedeps_new.py
@@ -868,6 +868,9 @@ class TestToolRequires:
                                                               .with_tool_requires("tool/0.1")})
         c.run("create tool")
         c.run(f"install pkg -g CMakeDeps -c tools.cmake.cmakedeps:new={new_value}")
+        assert "find_package(tool) # Optional. This is a tool-require, " \
+               "can't link its targets" in c.out
+        assert "target_link_libraries" not in c.out
         tool_config = c.load("pkg/tool-config.cmake")
         assert 'set(tool_INCLUDE_DIRS' not in tool_config
         assert 'set(tool_INCLUDE_DIR' not in tool_config