Browse Source

Merge pull request #7202 from aiordache/devtool28_compose_docker_contexts

Implement docker contexts to target different docker engines
Ulysses Souza 5 years ago
parent
commit
13bacba2b9
5 changed files with 120 additions and 36 deletions
  1. 21 28
      compose/cli/command.py
  2. 49 7
      compose/cli/docker_client.py
  3. 1 0
      compose/cli/main.py
  4. 1 1
      requirements.txt
  5. 48 0
      tests/acceptance/context_test.py

+ 21 - 28
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):

+ 49 - 7
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

+ 1 - 0
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

+ 1 - 1
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

+ 48 - 0
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