diff --git a/compose/cli/command.py b/compose/cli/command.py index 55f6df01a3..63d387f0c2 100644 --- a/compose/cli/command.py +++ b/compose/cli/command.py @@ -12,6 +12,7 @@ from .. import config from ..const import API_VERSIONS from ..project import Project from .docker_client import docker_client +from .docker_client import tls_config_from_options from .utils import get_version_info log = logging.getLogger(__name__) @@ -23,6 +24,8 @@ def project_from_options(project_dir, options): get_config_path_from_options(options), project_name=options.get('--project-name'), verbose=options.get('--verbose'), + host=options.get('--host'), + tls_config=tls_config_from_options(options), ) @@ -37,8 +40,8 @@ def get_config_path_from_options(options): return None -def get_client(verbose=False, version=None): - client = docker_client(version=version) +def get_client(verbose=False, version=None, tls_config=None, host=None): + client = docker_client(version=version, tls_config=tls_config, host=host) if verbose: version_info = six.iteritems(client.version()) log.info(get_version_info('full')) @@ -49,7 +52,8 @@ def get_client(verbose=False, version=None): return client -def get_project(project_dir, config_path=None, project_name=None, verbose=False): +def get_project(project_dir, config_path=None, project_name=None, verbose=False, + host=None, tls_config=None): config_details = config.find(project_dir, config_path) project_name = get_project_name(config_details.working_dir, project_name) config_data = config.load(config_details) @@ -57,7 +61,10 @@ def get_project(project_dir, config_path=None, project_name=None, verbose=False) api_version = os.environ.get( 'COMPOSE_API_VERSION', API_VERSIONS[config_data.version]) - client = get_client(verbose=verbose, version=api_version) + client = get_client( + verbose=verbose, version=api_version, tls_config=tls_config, + host=host + ) return Project.from_config(project_name, config_data, client) diff --git a/compose/cli/docker_client.py b/compose/cli/docker_client.py index 9e79fe7777..deb5686601 100644 --- a/compose/cli/docker_client.py +++ b/compose/cli/docker_client.py @@ -6,7 +6,9 @@ import os from docker import Client from docker.errors import TLSParameterError +from docker.tls import TLSConfig from docker.utils import kwargs_from_env +from requests.utils import urlparse from ..const import HTTP_TIMEOUT from .errors import UserError @@ -14,7 +16,33 @@ from .errors import UserError log = logging.getLogger(__name__) -def docker_client(version=None): +def tls_config_from_options(options): + tls = options.get('--tls', False) + ca_cert = options.get('--tlscacert') + cert = options.get('--tlscert') + key = options.get('--tlskey') + verify = options.get('--tlsverify') + hostname = urlparse(options.get('--host') or '').hostname + + advanced_opts = any([ca_cert, cert, key, verify]) + + if tls is True and not advanced_opts: + return True + elif advanced_opts: + client_cert = None + if cert or key: + client_cert = (cert, key) + return TLSConfig( + client_cert=client_cert, verify=verify, ca_cert=ca_cert, + assert_hostname=( + hostname or not options.get('--skip-hostname-check', False) + ) + ) + else: + return None + + +def docker_client(version=None, tls_config=None, host=None): """ Returns a docker-py client configured using environment variables according to the same logic as the official Docker client. @@ -31,6 +59,11 @@ def docker_client(version=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 version: kwargs['version'] = version diff --git a/compose/cli/main.py b/compose/cli/main.py index a978579c0f..6eada097f4 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -145,10 +145,20 @@ class TopLevelCommand(object): docker-compose -h|--help Options: - -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) - -p, --project-name NAME Specify an alternate project name (default: directory name) - --verbose Show more output - -v, --version Print version and exit + -f, --file FILE Specify an alternate compose file (default: docker-compose.yml) + -p, --project-name NAME Specify an alternate project name (default: directory name) + --verbose Show more output + -v, --version Print version and exit + -H, --host HOST Daemon socket to connect to + + --tls Use TLS; implied by --tlsverify + --tlscacert CA_PATH Trust certs signed only by this CA + --tlscert CLIENT_CERT_PATH Path to TLS certificate file + --tlskey TLS_KEY_PATH Path to TLS key file + --tlsverify Use TLS and verify the remote + --skip-hostname-check Don't check the daemon's hostname against the name specified + in the client certificate (for example if your docker host + is an IP address) Commands: build Build or rebuild services diff --git a/requirements.txt b/requirements.txt index 2b7c85e6a4..8836735381 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,7 +3,7 @@ cached-property==1.2.0 dockerpty==0.4.1 docopt==0.6.1 enum34==1.0.4 -git+https://github.com/docker/docker-py.git@8c4546f8c8f52bb2923834783a17beb5bb89a724#egg=docker-py +git+https://github.com/docker/docker-py.git@5c1c42397cf0fdb74182df2d69822b82df8f2a6a#egg=docker-py jsonschema==2.5.1 requests==2.7.0 six==1.7.3 diff --git a/tests/fixtures/tls/ca.pem b/tests/fixtures/tls/ca.pem new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/fixtures/tls/cert.pem b/tests/fixtures/tls/cert.pem new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/fixtures/tls/key.key b/tests/fixtures/tls/key.key new file mode 100644 index 0000000000..e69de29bb2 diff --git a/tests/unit/cli/docker_client_test.py b/tests/unit/cli/docker_client_test.py index d497495b40..b55f1d1799 100644 --- a/tests/unit/cli/docker_client_test.py +++ b/tests/unit/cli/docker_client_test.py @@ -3,7 +3,11 @@ from __future__ import unicode_literals import os -from compose.cli import docker_client +import docker +import pytest + +from compose.cli.docker_client import docker_client +from compose.cli.docker_client import tls_config_from_options from tests import mock from tests import unittest @@ -13,10 +17,89 @@ class DockerClientTestCase(unittest.TestCase): def test_docker_client_no_home(self): with mock.patch.dict(os.environ): del os.environ['HOME'] - docker_client.docker_client() + docker_client() def test_docker_client_with_custom_timeout(self): timeout = 300 with mock.patch('compose.cli.docker_client.HTTP_TIMEOUT', 300): - client = docker_client.docker_client() + client = docker_client() self.assertEqual(client.timeout, int(timeout)) + + +class TLSConfigTestCase(unittest.TestCase): + ca_cert = 'tests/fixtures/tls/ca.pem' + client_cert = 'tests/fixtures/tls/cert.pem' + key = 'tests/fixtures/tls/key.key' + + def test_simple_tls(self): + options = {'--tls': True} + result = tls_config_from_options(options) + assert result is True + + def test_tls_ca_cert(self): + options = { + '--tlscacert': self.ca_cert, '--tlsverify': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_ca_cert_explicit(self): + options = { + '--tlscacert': self.ca_cert, '--tls': True, + '--tlsverify': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_cert(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + + def test_tls_client_cert_explicit(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tls': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + + def test_tls_client_and_ca(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tlsverify': True, '--tlscacert': self.ca_cert + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_and_ca_explicit(self): + options = { + '--tlscert': self.client_cert, '--tlskey': self.key, + '--tlsverify': True, '--tlscacert': self.ca_cert, + '--tls': True + } + result = tls_config_from_options(options) + assert isinstance(result, docker.tls.TLSConfig) + assert result.cert == (options['--tlscert'], options['--tlskey']) + assert result.ca_cert == options['--tlscacert'] + assert result.verify is True + + def test_tls_client_missing_key(self): + options = {'--tlscert': self.client_cert} + with pytest.raises(docker.errors.TLSParameterError): + tls_config_from_options(options) + + options = {'--tlskey': self.key} + with pytest.raises(docker.errors.TLSParameterError): + tls_config_from_options(options)