Resolves #1066, use labels to identify containers

Signed-off-by: Daniel Nephin <dnephin@gmail.com>
This commit is contained in:
Daniel Nephin 2015-04-26 17:09:20 -04:00
parent 28d2aff8b8
commit ed50a0a3a0
9 changed files with 138 additions and 102 deletions

View File

@ -1,4 +1,3 @@
from __future__ import unicode_literals from __future__ import unicode_literals
from .service import Service # noqa:flake8
__version__ = '1.3.0dev' __version__ = '1.3.0dev'

6
compose/const.py Normal file
View File

@ -0,0 +1,6 @@
LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number'
LABEL_ONE_OFF = 'com.docker.compose.oneoff'
LABEL_PROJECT = 'com.docker.compose.project'
LABEL_SERVICE = 'com.docker.compose.service'
LABEL_VERSION = 'com.docker.compose.version'

View File

@ -4,6 +4,8 @@ from __future__ import absolute_import
import six import six
from functools import reduce from functools import reduce
from .const import LABEL_CONTAINER_NUMBER, LABEL_SERVICE
class Container(object): class Container(object):
""" """
@ -58,14 +60,11 @@ class Container(object):
@property @property
def name_without_project(self): def name_without_project(self):
return '_'.join(self.dictionary['Name'].split('_')[1:]) return '{0}_{1}'.format(self.labels.get(LABEL_SERVICE), self.number)
@property @property
def number(self): def number(self):
try: return int(self.labels.get(LABEL_CONTAINER_NUMBER) or 0)
return int(self.name.split('_')[-1])
except ValueError:
return None
@property @property
def ports(self): def ports(self):
@ -159,6 +158,7 @@ class Container(object):
self.has_been_inspected = True self.has_been_inspected = True
return self.dictionary return self.dictionary
# TODO: only used by tests, move to test module
def links(self): def links(self):
links = [] links = []
for container in self.client.containers(): for container in self.client.containers():

View File

@ -4,6 +4,7 @@ import logging
from functools import reduce from functools import reduce
from .config import get_service_name_from_net, ConfigurationError from .config import get_service_name_from_net, ConfigurationError
from .const import LABEL_PROJECT, LABEL_ONE_OFF
from .service import Service from .service import Service
from .container import Container from .container import Container
from docker.errors import APIError from docker.errors import APIError
@ -60,6 +61,12 @@ class Project(object):
self.services = services self.services = services
self.client = client self.client = client
def labels(self, one_off=False):
return [
'{0}={1}'.format(LABEL_PROJECT, self.name),
'{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False"),
]
@classmethod @classmethod
def from_dicts(cls, name, service_dicts, client): def from_dicts(cls, name, service_dicts, client):
""" """
@ -224,9 +231,9 @@ class Project(object):
def containers(self, service_names=None, stopped=False, one_off=False): def containers(self, service_names=None, stopped=False, one_off=False):
return [Container.from_ps(self.client, container) return [Container.from_ps(self.client, container)
for container in self.client.containers(all=stopped) for container in self.client.containers(
for service in self.get_services(service_names) all=stopped,
if service.has_container(container, one_off=one_off)] filters={'label': self.labels(one_off=one_off)})]
def _inject_deps(self, acc, service): def _inject_deps(self, acc, service):
net_name = service.get_net_name() net_name = service.get_net_name()

View File

@ -10,8 +10,16 @@ import six
from docker.errors import APIError from docker.errors import APIError
from docker.utils import create_host_config, LogConfig from docker.utils import create_host_config, LogConfig
from . import __version__
from .config import DOCKER_CONFIG_KEYS, merge_environment from .config import DOCKER_CONFIG_KEYS, merge_environment
from .container import Container, get_container_name from .const import (
LABEL_CONTAINER_NUMBER,
LABEL_ONE_OFF,
LABEL_PROJECT,
LABEL_SERVICE,
LABEL_VERSION,
)
from .container import Container
from .progress_stream import stream_output, StreamOutputError from .progress_stream import stream_output, StreamOutputError
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@ -79,26 +87,16 @@ class Service(object):
def containers(self, stopped=False, one_off=False): def containers(self, stopped=False, one_off=False):
return [Container.from_ps(self.client, container) return [Container.from_ps(self.client, container)
for container in self.client.containers(all=stopped) for container in self.client.containers(
if self.has_container(container, one_off=one_off)] all=stopped,
filters={'label': self.labels(one_off=one_off)})]
def has_container(self, container, one_off=False):
"""Return True if `container` was created to fulfill this service."""
name = get_container_name(container)
if not name or not is_valid_name(name, one_off):
return False
project, name, _number = parse_name(name)
return project == self.project and name == self.name
def get_container(self, number=1): def get_container(self, number=1):
"""Return a :class:`compose.container.Container` for this service. The """Return a :class:`compose.container.Container` for this service. The
container must be active, and match `number`. container must be active, and match `number`.
""" """
for container in self.client.containers(): labels = self.labels() + ['{0}={1}'.format(LABEL_CONTAINER_NUMBER, number)]
if not self.has_container(container): for container in self.client.containers(filters={'label': labels}):
continue
_, _, container_number = parse_name(get_container_name(container))
if container_number == number:
return Container.from_ps(self.client, container) return Container.from_ps(self.client, container)
raise ValueError("No container found for %s_%s" % (self.name, number)) raise ValueError("No container found for %s_%s" % (self.name, number))
@ -138,7 +136,6 @@ class Service(object):
# Create enough containers # Create enough containers
containers = self.containers(stopped=True) containers = self.containers(stopped=True)
while len(containers) < desired_num: while len(containers) < desired_num:
log.info("Creating %s..." % self._next_container_name(containers))
containers.append(self.create_container(detach=True)) containers.append(self.create_container(detach=True))
running_containers = [] running_containers = []
@ -178,6 +175,7 @@ class Service(object):
insecure_registry=False, insecure_registry=False,
do_build=True, do_build=True,
previous_container=None, previous_container=None,
number=None,
**override_options): **override_options):
""" """
Create a container for this service. If the image doesn't exist, attempt to pull Create a container for this service. If the image doesn't exist, attempt to pull
@ -185,6 +183,7 @@ class Service(object):
""" """
container_options = self._get_container_create_options( container_options = self._get_container_create_options(
override_options, override_options,
number or self._next_container_number(one_off=one_off),
one_off=one_off, one_off=one_off,
previous_container=previous_container, previous_container=previous_container,
) )
@ -209,7 +208,6 @@ class Service(object):
""" """
containers = self.containers(stopped=True) containers = self.containers(stopped=True)
if not containers: if not containers:
log.info("Creating %s..." % self._next_container_name(containers))
container = self.create_container( container = self.create_container(
insecure_registry=insecure_registry, insecure_registry=insecure_registry,
do_build=do_build, do_build=do_build,
@ -256,6 +254,7 @@ class Service(object):
new_container = self.create_container( new_container = self.create_container(
do_build=False, do_build=False,
previous_container=container, previous_container=container,
number=container.labels.get(LABEL_CONTAINER_NUMBER),
**override_options) **override_options)
self.start_container(new_container) self.start_container(new_container)
container.remove() container.remove()
@ -280,7 +279,6 @@ class Service(object):
containers = self.containers(stopped=True) containers = self.containers(stopped=True)
if not containers: if not containers:
log.info("Creating %s..." % self._next_container_name(containers))
new_container = self.create_container( new_container = self.create_container(
insecure_registry=insecure_registry, insecure_registry=insecure_registry,
detach=detach, detach=detach,
@ -302,14 +300,19 @@ class Service(object):
else: else:
return return
def _next_container_name(self, all_containers, one_off=False): def get_container_name(self, number, one_off=False):
bits = [self.project, self.name] # TODO: Implement issue #652 here
if one_off: return build_container_name(self.project, self.name, number, one_off)
bits.append('run')
return '_'.join(bits + [str(self._next_container_number(all_containers))])
def _next_container_number(self, all_containers): # TODO: this would benefit from github.com/docker/docker/pull/11943
numbers = [parse_name(c.name).number for c in all_containers] # to remove the need to inspect every container
def _next_container_number(self, one_off=False):
numbers = [
Container.from_ps(self.client, container).number
for container in self.client.containers(
all=True,
filters={'label': self.labels(one_off=one_off)})
]
return 1 if not numbers else max(numbers) + 1 return 1 if not numbers else max(numbers) + 1
def _get_links(self, link_to_self): def _get_links(self, link_to_self):
@ -369,6 +372,7 @@ class Service(object):
def _get_container_create_options( def _get_container_create_options(
self, self,
override_options, override_options,
number,
one_off=False, one_off=False,
previous_container=None): previous_container=None):
container_options = dict( container_options = dict(
@ -376,9 +380,7 @@ class Service(object):
for k in DOCKER_CONFIG_KEYS if k in self.options) for k in DOCKER_CONFIG_KEYS if k in self.options)
container_options.update(override_options) container_options.update(override_options)
container_options['name'] = self._next_container_name( container_options['name'] = self.get_container_name(number, one_off)
self.containers(stopped=True, one_off=one_off),
one_off)
# If a qualified hostname was given, split it into an # If a qualified hostname was given, split it into an
# unqualified hostname and a domainname unless domainname # unqualified hostname and a domainname unless domainname
@ -419,6 +421,11 @@ class Service(object):
if self.can_be_built(): if self.can_be_built():
container_options['image'] = self.full_name container_options['image'] = self.full_name
container_options['labels'] = build_container_labels(
container_options.get('labels', {}),
self.labels(one_off=one_off),
number)
# Delete options which are only used when starting # Delete options which are only used when starting
for key in DOCKER_START_KEYS: for key in DOCKER_START_KEYS:
container_options.pop(key, None) container_options.pop(key, None)
@ -520,6 +527,13 @@ class Service(object):
""" """
return '%s_%s' % (self.project, self.name) return '%s_%s' % (self.project, self.name)
def labels(self, one_off=False):
return [
'{0}={1}'.format(LABEL_PROJECT, self.project),
'{0}={1}'.format(LABEL_SERVICE, self.name),
'{0}={1}'.format(LABEL_ONE_OFF, "True" if one_off else "False")
]
def can_be_scaled(self): def can_be_scaled(self):
for port in self.options.get('ports', []): for port in self.options.get('ports', []):
if ':' in str(port): if ':' in str(port):
@ -585,23 +599,19 @@ def merge_volume_bindings(volumes_option, previous_container):
return volume_bindings return volume_bindings
NAME_RE = re.compile(r'^([^_]+)_([^_]+)_(run_)?(\d+)$') def build_container_name(project, service, number, one_off=False):
bits = [project, service]
def is_valid_name(name, one_off=False):
match = NAME_RE.match(name)
if match is None:
return False
if one_off: if one_off:
return match.group(3) == 'run_' bits.append('run')
else: return '_'.join(bits + [str(number)])
return match.group(3) is None
def parse_name(name): def build_container_labels(label_options, service_labels, number, one_off=False):
match = NAME_RE.match(name) labels = label_options or {}
(project, service_name, _, suffix) = match.groups() labels.update(label.split('=', 1) for label in service_labels)
return ServiceName(project, service_name, int(suffix)) labels[LABEL_CONTAINER_NUMBER] = str(number)
labels[LABEL_VERSION] = __version__
return labels
def parse_restart_spec(restart_config): def parse_restart_spec(restart_config):

View File

@ -8,11 +8,19 @@ import tempfile
import shutil import shutil
import six import six
from compose import Service from compose import __version__
from compose.const import (
LABEL_CONTAINER_NUMBER,
LABEL_ONE_OFF,
LABEL_PROJECT,
LABEL_SERVICE,
LABEL_VERSION,
)
from compose.service import ( from compose.service import (
CannotBeScaledError, CannotBeScaledError,
build_extra_hosts,
ConfigError, ConfigError,
Service,
build_extra_hosts,
) )
from compose.container import Container from compose.container import Container
from docker.errors import APIError from docker.errors import APIError
@ -633,17 +641,18 @@ class ServiceTest(DockerClientTestCase):
'com.example.label-with-empty-value': "", 'com.example.label-with-empty-value': "",
} }
compose_labels = {
LABEL_CONTAINER_NUMBER: '1',
LABEL_ONE_OFF: 'False',
LABEL_PROJECT: 'composetest',
LABEL_SERVICE: 'web',
LABEL_VERSION: __version__,
}
expected = dict(labels_dict, **compose_labels)
service = self.create_service('web', labels=labels_dict) service = self.create_service('web', labels=labels_dict)
labels = create_and_start_container(service).labels.items() labels = create_and_start_container(service).labels
for pair in labels_dict.items(): self.assertEqual(labels, expected)
self.assertIn(pair, labels)
labels_list = ["%s=%s" % pair for pair in labels_dict.items()]
service = self.create_service('web', labels=labels_list)
labels = create_and_start_container(service).labels.items()
for pair in labels_dict.items():
self.assertIn(pair, labels)
def test_empty_labels(self): def test_empty_labels(self):
labels_list = ['foo', 'bar'] labels_list = ['foo', 'bar']

View File

@ -12,6 +12,7 @@ class DockerClientTestCase(unittest.TestCase):
def setUpClass(cls): def setUpClass(cls):
cls.client = docker_client() cls.client = docker_client()
# TODO: update to use labels in #652
def setUp(self): def setUp(self):
for c in self.client.containers(all=True): for c in self.client.containers(all=True):
if c['Names'] and 'composetest' in c['Names'][0]: if c['Names'] and 'composetest' in c['Names'][0]:

View File

@ -5,6 +5,7 @@ import mock
import docker import docker
from compose.container import Container from compose.container import Container
from compose.container import get_container_name
class ContainerTest(unittest.TestCase): class ContainerTest(unittest.TestCase):
@ -23,6 +24,13 @@ class ContainerTest(unittest.TestCase):
"NetworkSettings": { "NetworkSettings": {
"Ports": {}, "Ports": {},
}, },
"Config": {
"Labels": {
"com.docker.compose.project": "composetest",
"com.docker.compose.service": "web",
"com.docker.compose.container_number": 7,
},
}
} }
def test_from_ps(self): def test_from_ps(self):
@ -65,10 +73,8 @@ class ContainerTest(unittest.TestCase):
}) })
def test_number(self): def test_number(self):
container = Container.from_ps(None, container = Container(None, self.container_dict, has_been_inspected=True)
self.container_dict, self.assertEqual(container.number, 7)
has_been_inspected=True)
self.assertEqual(container.number, 1)
def test_name(self): def test_name(self):
container = Container.from_ps(None, container = Container.from_ps(None,
@ -77,10 +83,8 @@ class ContainerTest(unittest.TestCase):
self.assertEqual(container.name, "composetest_db_1") self.assertEqual(container.name, "composetest_db_1")
def test_name_without_project(self): def test_name_without_project(self):
container = Container.from_ps(None, container = Container(None, self.container_dict, has_been_inspected=True)
self.container_dict, self.assertEqual(container.name_without_project, "web_7")
has_been_inspected=True)
self.assertEqual(container.name_without_project, "db_1")
def test_inspect_if_not_inspected(self): def test_inspect_if_not_inspected(self):
mock_client = mock.create_autospec(docker.Client) mock_client = mock.create_autospec(docker.Client)
@ -130,3 +134,12 @@ class ContainerTest(unittest.TestCase):
self.assertEqual(container.get('Status'), "Up 8 seconds") self.assertEqual(container.get('Status'), "Up 8 seconds")
self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"]) self.assertEqual(container.get('HostConfig.VolumesFrom'), ["volume_id"])
self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None) self.assertEqual(container.get('Foo.Bar.DoesNotExist'), None)
class GetContainerNameTestCase(unittest.TestCase):
def test_get_container_name(self):
self.assertIsNone(get_container_name({}))
self.assertEqual(get_container_name({'Name': 'myproject_db_1'}), 'myproject_db_1')
self.assertEqual(get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), 'myproject_db_1')
self.assertEqual(get_container_name({'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1')

View File

@ -7,15 +7,15 @@ import mock
import docker import docker
from requests import Response from requests import Response
from compose import Service from compose.service import Service
from compose.container import Container from compose.container import Container
from compose.const import LABEL_SERVICE, LABEL_PROJECT, LABEL_ONE_OFF
from compose.service import ( from compose.service import (
APIError, APIError,
ConfigError, ConfigError,
build_port_bindings, build_port_bindings,
build_volume_binding, build_volume_binding,
get_container_data_volumes, get_container_data_volumes,
get_container_name,
merge_volume_bindings, merge_volume_bindings,
parse_repository_tag, parse_repository_tag,
parse_volume_spec, parse_volume_spec,
@ -48,36 +48,27 @@ class ServiceTest(unittest.TestCase):
self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo')) self.assertRaises(ConfigError, lambda: Service(name='foo', project='_', image='foo'))
Service(name='foo', project='bar', image='foo') Service(name='foo', project='bar', image='foo')
def test_get_container_name(self):
self.assertIsNone(get_container_name({}))
self.assertEqual(get_container_name({'Name': 'myproject_db_1'}), 'myproject_db_1')
self.assertEqual(get_container_name({'Names': ['/myproject_db_1', '/myproject_web_1/db']}), 'myproject_db_1')
self.assertEqual(get_container_name({'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']}), 'myproject_db_1')
def test_containers(self): def test_containers(self):
service = Service('db', client=self.mock_client, image='foo', project='myproject') service = Service('db', self.mock_client, 'myproject', image='foo')
self.mock_client.containers.return_value = [] self.mock_client.containers.return_value = []
self.assertEqual(service.containers(), []) self.assertEqual(service.containers(), [])
def test_containers_with_containers(self):
self.mock_client.containers.return_value = [ self.mock_client.containers.return_value = [
{'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/myproject', '/foo/bar']}, dict(Name=str(i), Image='foo', Id=i) for i in range(3)
{'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/myproject_db']},
{'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/db_1']},
{'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/myproject_db_1', '/myproject_web_1/db']},
] ]
self.assertEqual([c.id for c in service.containers()], ['IN_1']) service = Service('db', self.mock_client, 'myproject', image='foo')
self.assertEqual([c.id for c in service.containers()], range(3))
def test_containers_prefixed(self): expected_labels = [
service = Service('db', client=self.mock_client, image='foo', project='myproject') '{0}=myproject'.format(LABEL_PROJECT),
'{0}=db'.format(LABEL_SERVICE),
self.mock_client.containers.return_value = [ '{0}=False'.format(LABEL_ONE_OFF),
{'Image': 'busybox', 'Id': 'OUT_1', 'Names': ['/swarm-host-1/myproject', '/swarm-host-1/foo/bar']},
{'Image': 'busybox', 'Id': 'OUT_2', 'Names': ['/swarm-host-1/myproject_db']},
{'Image': 'busybox', 'Id': 'OUT_3', 'Names': ['/swarm-host-1/db_1']},
{'Image': 'busybox', 'Id': 'IN_1', 'Names': ['/swarm-host-1/myproject_db_1', '/swarm-host-1/myproject_web_1/db']},
] ]
self.assertEqual([c.id for c in service.containers()], ['IN_1'])
self.mock_client.containers.assert_called_once_with(
all=False,
filters={'label': expected_labels})
def test_get_volumes_from_container(self): def test_get_volumes_from_container(self):
container_id = 'aabbccddee' container_id = 'aabbccddee'
@ -156,7 +147,7 @@ class ServiceTest(unittest.TestCase):
def test_split_domainname_none(self): def test_split_domainname_none(self):
service = Service('foo', image='foo', hostname='name', client=self.mock_client) service = Service('foo', image='foo', hostname='name', client=self.mock_client)
self.mock_client.containers.return_value = [] self.mock_client.containers.return_value = []
opts = service._get_container_create_options({'image': 'foo'}) opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['hostname'], 'name', 'hostname')
self.assertFalse('domainname' in opts, 'domainname') self.assertFalse('domainname' in opts, 'domainname')
@ -167,7 +158,7 @@ class ServiceTest(unittest.TestCase):
image='foo', image='foo',
client=self.mock_client) client=self.mock_client)
self.mock_client.containers.return_value = [] self.mock_client.containers.return_value = []
opts = service._get_container_create_options({'image': 'foo'}) opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['hostname'], 'name', 'hostname')
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
@ -179,7 +170,7 @@ class ServiceTest(unittest.TestCase):
domainname='domain.tld', domainname='domain.tld',
client=self.mock_client) client=self.mock_client)
self.mock_client.containers.return_value = [] self.mock_client.containers.return_value = []
opts = service._get_container_create_options({'image': 'foo'}) opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name', 'hostname') self.assertEqual(opts['hostname'], 'name', 'hostname')
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
@ -191,7 +182,7 @@ class ServiceTest(unittest.TestCase):
image='foo', image='foo',
client=self.mock_client) client=self.mock_client)
self.mock_client.containers.return_value = [] self.mock_client.containers.return_value = []
opts = service._get_container_create_options({'image': 'foo'}) opts = service._get_container_create_options({'image': 'foo'}, 1)
self.assertEqual(opts['hostname'], 'name.sub', 'hostname') self.assertEqual(opts['hostname'], 'name.sub', 'hostname')
self.assertEqual(opts['domainname'], 'domain.tld', 'domainname') self.assertEqual(opts['domainname'], 'domain.tld', 'domainname')
@ -255,7 +246,7 @@ class ServiceTest(unittest.TestCase):
tag='sometag', tag='sometag',
insecure_registry=True, insecure_registry=True,
stream=True) stream=True)
mock_log.info.assert_called_once_with( mock_log.info.assert_called_with(
'Pulling foo (someimage:sometag)...') 'Pulling foo (someimage:sometag)...')
@mock.patch('compose.service.Container', autospec=True) @mock.patch('compose.service.Container', autospec=True)