diff --git a/compose/cli/command.py b/compose/cli/command.py index 2fabbe18a..7621134e8 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -8,7 +8,6 @@ import re import six from . import errors -from . import verbose_proxy from .. import config from .. import parallel from ..config.environment import Environment @@ -17,10 +16,10 @@ from ..const import LABEL_CONFIG_FILES from ..const import LABEL_ENVIRONMENT_FILE from ..const import LABEL_WORKING_DIR from ..project import Project -from .docker_client import docker_client -from .docker_client import get_tls_version -from .docker_client import tls_config_from_options -from .utils import get_version_info +from .docker_client import get_client +from .docker_client import load_context +from .docker_client import make_context +from .errors import UserError log = logging.getLogger(__name__) @@ -48,16 +47,28 @@ def project_from_options(project_dir, options, additional_options=None): environment.silent = options.get('COMMAND', None) in SILENT_COMMANDS set_parallel_limit(environment) - host = options.get('--host') + # get the context for the run + context = None + context_name = options.get('--context', None) + if context_name: + context = load_context(context_name) + if not context: + raise UserError("Context '{}' not found".format(context_name)) + + host = options.get('--host', None) if host is not None: + if context: + raise UserError( + "-H, --host and -c, --context are mutually exclusive. Only one should be set.") host = host.lstrip('=') + context = make_context(host, options, environment) + return get_project( project_dir, get_config_path_from_options(project_dir, options, environment), project_name=options.get('--project-name'), verbose=options.get('--verbose'), - host=host, - tls_config=tls_config_from_options(options, environment), + context=context, environment=environment, override_dir=override_dir, compatibility=compatibility_from_options(project_dir, options, environment), @@ -112,25 +123,8 @@ def get_config_path_from_options(base_dir, options, environment): return None -def get_client(environment, verbose=False, version=None, tls_config=None, host=None, - tls_version=None): - - client = docker_client( - version=version, tls_config=tls_config, host=host, - environment=environment, tls_version=get_tls_version(environment) - ) - if verbose: - version_info = six.iteritems(client.version()) - log.info(get_version_info('full')) - log.info("Docker base_url: %s", client.base_url) - log.info("Docker version: %s", - ", ".join("%s=%s" % item for item in version_info)) - return verbose_proxy.VerboseProxy('docker', client) - return client - - def get_project(project_dir, config_path=None, project_name=None, verbose=False, - host=None, tls_config=None, environment=None, override_dir=None, + context=None, environment=None, override_dir=None, compatibility=False, interpolate=True, environment_file=None): if not environment: environment = Environment.from_env_file(project_dir) @@ -145,8 +139,7 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False, API_VERSIONS[config_data.version]) client = get_client( - verbose=verbose, version=api_version, tls_config=tls_config, - host=host, environment=environment + verbose=verbose, version=api_version, context=context, environment=environment ) with errors.handle_connection_errors(client): diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index a57a69b50..d4cdc96e8 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -5,17 +5,22 @@ import logging import os.path import ssl +import six from docker import APIClient +from docker import Context +from docker import ContextAPI +from docker import TLSConfig from docker.errors import TLSParameterError -from docker.tls import TLSConfig from docker.utils import kwargs_from_env from docker.utils.config import home_dir +from . import verbose_proxy from ..config.environment import Environment from ..const import HTTP_TIMEOUT from ..utils import unquote_path from .errors import UserError from .utils import generate_user_agent +from .utils import get_version_info log = logging.getLogger(__name__) @@ -24,6 +29,33 @@ def default_cert_path(): return os.path.join(home_dir(), '.docker') +def make_context(host, options, environment): + tls = tls_config_from_options(options, environment) + ctx = Context("compose", host=host) + if tls: + ctx.set_endpoint("docker", host, tls, skip_tls_verify=not tls.verify) + return ctx + + +def load_context(name=None): + return ContextAPI.get_context(name) + + +def get_client(environment, verbose=False, version=None, context=None): + client = docker_client( + version=version, context=context, + environment=environment, tls_version=get_tls_version(environment) + ) + if verbose: + version_info = six.iteritems(client.version()) + log.info(get_version_info('full')) + log.info("Docker base_url: %s", client.base_url) + log.info("Docker version: %s", + ", ".join("%s=%s" % item for item in version_info)) + return verbose_proxy.VerboseProxy('docker', client) + return client + + def get_tls_version(environment): compose_tls_version = environment.get('COMPOSE_TLS_VERSION', None) if not compose_tls_version: @@ -87,8 +119,7 @@ def tls_config_from_options(options, environment=None): return None -def docker_client(environment, version=None, tls_config=None, host=None, - tls_version=None): +def docker_client(environment, version=None, context=None, tls_version=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -101,10 +132,21 @@ def docker_client(environment, version=None, tls_config=None, host=None, "and DOCKER_CERT_PATH are set correctly.\n" "You might need to run `eval \"$(docker-machine env default)\"`") - if host: - kwargs['base_url'] = host - if tls_config: - kwargs['tls'] = tls_config + if not context: + # check env for DOCKER_HOST and certs path + host = kwargs.get("base_url", None) + tls = kwargs.get("tls", None) + verify = False if not tls else tls.verify + if host: + context = Context("compose", host=host) + else: + context = ContextAPI.get_current_context() + if tls: + context.set_endpoint("docker", host=host, tls_cfg=tls, skip_tls_verify=not verify) + + kwargs['base_url'] = context.Host + if context.TLSConfig: + kwargs['tls'] = context.TLSConfig if version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index 3f0f88384..e226a6008 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -192,6 +192,7 @@ class TopLevelCommand(object): (default: docker-compose.yml) -p, --project-name NAME Specify an alternate project name (default: directory name) + -c, --context NAME Specify a context name --verbose Show more output --log-level LEVEL Set log level (DEBUG, INFO, WARNING, ERROR, CRITICAL) --no-ansi Do not print ANSI control characters diff --git a/requirements.txt b/requirements.txt index ee57c26b5..a3e8fc5d3 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,7 +4,7 @@ cached-property==1.5.1 certifi==2019.11.28 chardet==3.0.4 colorama==0.4.3; sys_platform == 'win32' -docker==4.1.0 +docker==4.2.0 docker-pycreds==0.4.0 dockerpty==0.4.1 docopt==0.6.2 diff --git a/tests/acceptance/context_test.py b/tests/acceptance/context_test.py new file mode 100644 index 000000000..1d79a22a0 --- /dev/null +++ b/tests/acceptance/context_test.py @@ -0,0 +1,48 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import +from __future__ import unicode_literals + +import os +import shutil +import unittest + +from docker import ContextAPI + +from tests.acceptance.cli_test import dispatch + + +class ContextTestCase(unittest.TestCase): + @classmethod + def setUpClass(cls): + cls.docker_dir = os.path.join(os.environ.get("HOME", "/tmp"), '.docker') + if not os.path.exists(cls.docker_dir): + os.makedirs(cls.docker_dir) + f = open(os.path.join(cls.docker_dir, "config.json"), "w") + f.write("{}") + f.close() + cls.docker_config = os.path.join(cls.docker_dir, "config.json") + os.environ['DOCKER_CONFIG'] = cls.docker_config + ContextAPI.create_context("testcontext", host="tcp://doesnotexist:8000") + + @classmethod + def tearDownClass(cls): + shutil.rmtree(cls.docker_dir, ignore_errors=True) + + def setUp(self): + self.base_dir = 'tests/fixtures/simple-composefile' + self.override_dir = None + + def dispatch(self, options, project_options=None, returncode=0, stdin=None): + return dispatch(self.base_dir, options, project_options, returncode, stdin) + + def test_help(self): + result = self.dispatch(['help'], returncode=0) + assert '-c, --context NAME' in result.stdout + + def test_fail_on_both_host_and_context_opt(self): + result = self.dispatch(['-H', 'unix://', '-c', 'default', 'up'], returncode=1) + assert '-H, --host and -c, --context are mutually exclusive' in result.stderr + + def test_fail_run_on_inexistent_context(self): + result = self.dispatch(['-c', 'testcontext', 'up', '-d'], returncode=1) + assert "Couldn't connect to Docker daemon" in result.stderr