Skip to content

Commit

Permalink
Basic implementation of smart recreate
Browse files Browse the repository at this point in the history
Signed-off-by: Aanand Prasad <[email protected]>
  • Loading branch information
aanand committed May 12, 2015
1 parent 8e795fe commit 9326dbd
Show file tree
Hide file tree
Showing 10 changed files with 227 additions and 28 deletions.
4 changes: 4 additions & 0 deletions compose/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -442,6 +442,8 @@ def up(self, project, options):
print new container names.
--no-color Produce monochrome output.
--no-deps Don't start linked services.
--x-smart-recreate Only recreate containers whose configuration needs
to be updated. (EXPERIMENTAL)
--no-recreate If containers already exist, don't recreate them.
--no-build Don't build an image, even if it's missing
-t, --timeout TIMEOUT When attached, use this timeout in seconds
Expand All @@ -455,12 +457,14 @@ def up(self, project, options):

start_deps = not options['--no-deps']
recreate = not options['--no-recreate']
smart_recreate = options['--x-smart-recreate']
service_names = options['SERVICE']

project.up(
service_names=service_names,
start_deps=start_deps,
recreate=recreate,
smart_recreate=smart_recreate,
insecure_registry=insecure_registry,
do_build=not options['--no-build'],
)
Expand Down
1 change: 1 addition & 0 deletions compose/const.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
LABEL_CONFIG_HASH = 'com.docker.compose.config-hash'
5 changes: 4 additions & 1 deletion compose/container.py
Original file line number Diff line number Diff line change
Expand Up @@ -171,13 +171,16 @@ def attach_socket(self, **kwargs):
return self.client.attach_socket(self.id, **kwargs)

def __repr__(self):
return '<Container: %s>' % self.name
return '<Container: %s (%s)>' % (self.name, self.id[:6])

def __eq__(self, other):
if type(self) != type(other):
return False
return self.id == other.id

def __hash__(self):
return self.id.__hash__()


def get_container_name(container):
if not container.get('Name') and not container.get('Names'):
Expand Down
18 changes: 9 additions & 9 deletions compose/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -196,19 +196,19 @@ def up(self,
service_names=None,
start_deps=True,
recreate=True,
smart_recreate=False,
insecure_registry=False,
do_build=True):

running_containers = []
for service in self.get_services(service_names, include_deps=start_deps):
if recreate:
create_func = service.recreate_containers
else:
create_func = service.start_or_create_containers

for container in create_func(
insecure_registry=insecure_registry,
do_build=do_build):
running_containers.append(container)
for service in self.get_services(service_names, include_deps=start_deps):
running_containers += service.converge(
allow_recreate=recreate,
smart_recreate=smart_recreate,
insecure_registry=insecure_registry,
do_build=do_build,
)

return running_containers

Expand Down
94 changes: 85 additions & 9 deletions compose/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
from docker.utils import create_host_config, LogConfig

from .config import DOCKER_CONFIG_KEYS
from .const import LABEL_CONFIG_HASH
from .container import Container, get_container_name
from .progress_stream import stream_output, StreamOutputError
from .utils import json_hash

log = logging.getLogger(__name__)

Expand Down Expand Up @@ -201,26 +203,89 @@ def create_container(self,
return Container.create(self.client, **container_options)
raise

def recreate_containers(self, insecure_registry=False, do_build=True):
def converge(self,
allow_recreate=True,
smart_recreate=False,
insecure_registry=False,
do_build=True):
"""
If a container for this service doesn't exist, create and start one. If there are
any, stop them, create+start new ones, and remove the old containers.
"""
containers = self.containers(stopped=True)
if not containers:
(action, containers) = self.convergence_plan(
allow_recreate=allow_recreate,
smart_recreate=smart_recreate,
)

if action == 'create':
log.info("Creating %s..." % self._next_container_name(containers))
container = self.create_container(
insecure_registry=insecure_registry,
do_build=do_build)
do_build=do_build,
)
self.start_container(container)

return [container]

return [
self.recreate_container(c, insecure_registry=insecure_registry)
for c in containers
]
elif action == 'recreate':
return [
self.recreate_container(
c,
insecure_registry=insecure_registry,
)
for c in containers
]

elif action == 'start':
for c in containers:
self.start_container_if_stopped(c)

return containers

elif action == 'noop':
for c in containers:
log.info("%s is up-to-date" % c.name)

return containers

else:
raise Exception("Invalid action: {}".format(action))

def convergence_plan(self,
allow_recreate=True,
smart_recreate=False):

def recreate_container(self, container, insecure_registry=False):
containers = self.containers(stopped=True)

if not containers:
return ('create', [])

if smart_recreate and not self._containers_have_diverged(containers):
return ('noop', containers)

if not allow_recreate:
return ('start', containers)

return ('recreate', containers)

def _containers_have_diverged(self, containers):
config_hash = self.config_hash()
has_diverged = False

for c in containers:
container_config_hash = c.labels.get(LABEL_CONFIG_HASH, None)
if container_config_hash != config_hash:
log.debug(
'%s has diverged: %s != %s',
c.name, container_config_hash, config_hash,
)
has_diverged = True

return has_diverged

def recreate_container(self,
container,
insecure_registry=False):
"""Recreate a container.
The original container is renamed to a temporary name so that data
Expand Down Expand Up @@ -278,6 +343,9 @@ def start_or_create_containers(
else:
return [self.start_container_if_stopped(c) for c in containers]

def config_hash(self):
return json_hash(self.options)

def get_linked_names(self):
return [s.name for (s, _) in self.links]

Expand Down Expand Up @@ -369,6 +437,14 @@ def _get_container_create_options(self,
for k in DOCKER_CONFIG_KEYS if k in self.options)
container_options.update(override_options)

# TODO: add tests for this conditional
if not one_off and not override_options:
config_hash = self.config_hash()
if 'labels' not in container_options:
container_options['labels'] = {}
container_options['labels'][LABEL_CONFIG_HASH] = config_hash
log.debug("Added config hash: %s" % config_hash)

if 'detach' not in container_options:
container_options['detach'] = True

Expand Down
Empty file added compose/state.py
Empty file.
9 changes: 9 additions & 0 deletions compose/utils.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import json
import hashlib


def json_hash(obj):
dump = json.dumps(obj, sort_keys=True)
h = hashlib.sha256()
h.update(dump)
return h.hexdigest()
3 changes: 2 additions & 1 deletion tests/integration/project_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ def test_project_up_with_no_recreate_stopped(self):
self.assertEqual(len(project.containers()), 0)

project.up(['db'])
project.stop()
project.kill()

old_containers = project.containers(stopped=True)

Expand All @@ -216,6 +216,7 @@ def test_project_up_with_no_recreate_stopped(self):

new_containers = project.containers(stopped=True)
self.assertEqual(len(new_containers), 2)
self.assertEqual([c.is_running for c in new_containers], [True, True])

db_container = [c for c in new_containers if 'db' in c.name][0]
self.assertEqual(db_container.id, old_db_id)
Expand Down
19 changes: 11 additions & 8 deletions tests/integration/service_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
ConfigError,
)
from compose.container import Container
from compose.const import LABEL_CONFIG_HASH
from docker.errors import APIError
from .testcases import DockerClientTestCase

Expand Down Expand Up @@ -230,7 +231,7 @@ def test_create_container_with_volumes_from(self):
self.assertIn(volume_container_2.id,
host_container.get('HostConfig.VolumesFrom'))

def test_recreate_containers(self):
def test_converge(self):
service = self.create_service(
'db',
environment={'FOO': '1'},
Expand All @@ -249,7 +250,7 @@ def test_recreate_containers(self):
num_containers_before = len(self.client.containers(all=True))

service.options['environment']['FOO'] = '2'
new_container, = service.recreate_containers()
new_container, = service.converge()

self.assertEqual(new_container.dictionary['Config']['Entrypoint'], ['sleep'])
self.assertEqual(new_container.dictionary['Config']['Cmd'], ['300'])
Expand All @@ -264,7 +265,7 @@ def test_recreate_containers(self):
self.client.inspect_container,
old_container.id)

def test_recreate_containers_when_containers_are_stopped(self):
def test_converge_when_containers_are_stopped(self):
service = self.create_service(
'db',
environment={'FOO': '1'},
Expand All @@ -274,10 +275,10 @@ def test_recreate_containers_when_containers_are_stopped(self):
)
service.create_container()
self.assertEqual(len(service.containers(stopped=True)), 1)
service.recreate_containers()
service.converge()
self.assertEqual(len(service.containers(stopped=True)), 1)

def test_recreate_containers_with_image_declared_volume(self):
def test_converge_with_image_declared_volume(self):
service = Service(
project='composetest',
name='db',
Expand All @@ -289,7 +290,7 @@ def test_recreate_containers_with_image_declared_volume(self):
self.assertEqual(old_container.get('Volumes').keys(), ['/data'])
volume_path = old_container.get('Volumes')['/data']

service.recreate_containers()
service.converge()
new_container = service.containers()[0]
service.start_container(new_container)
self.assertEqual(new_container.get('Volumes').keys(), ['/data'])
Expand Down Expand Up @@ -635,14 +636,16 @@ def test_labels(self):
service = self.create_service('web', labels=labels_dict)
labels = create_and_start_container(service).labels.items()
for pair in labels_dict.items():
self.assertIn(pair, labels)
if pair[0] != LABEL_CONFIG_HASH:
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)
if pair[0] != LABEL_CONFIG_HASH:
self.assertIn(pair, labels)

def test_empty_labels(self):
labels_list = ['foo', 'bar']
Expand Down
Loading

0 comments on commit 9326dbd

Please sign in to comment.