summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--compose/config/compose_spec.json2
-rw-r--r--compose/errors.py5
-rw-r--r--compose/parallel.py10
-rw-r--r--compose/service.py19
-rw-r--r--tests/integration/project_test.py105
-rw-r--r--tests/unit/config/config_test.py11
6 files changed, 146 insertions, 6 deletions
diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json
index c0d48366..56f1d862 100644
--- a/compose/config/compose_spec.json
+++ b/compose/config/compose_spec.json
@@ -188,7 +188,7 @@
"properties": {
"condition": {
"type": "string",
- "enum": ["service_started", "service_healthy"]
+ "enum": ["service_started", "service_healthy", "service_completed_successfully"]
}
},
"required": ["condition"]
diff --git a/compose/errors.py b/compose/errors.py
index d4fead25..502b64b8 100644
--- a/compose/errors.py
+++ b/compose/errors.py
@@ -27,3 +27,8 @@ class NoHealthCheckConfigured(HealthCheckException):
service_name
)
)
+
+
+class CompletedUnsuccessfully(Exception):
+ def __init__(self, container_id, exit_code):
+ self.msg = 'Container "{}" exited with code {}.'.format(container_id, exit_code)
diff --git a/compose/parallel.py b/compose/parallel.py
index 74d3e3c0..316e2217 100644
--- a/compose/parallel.py
+++ b/compose/parallel.py
@@ -16,6 +16,7 @@ from compose.cli.colors import green
from compose.cli.colors import red
from compose.cli.signals import ShutdownException
from compose.const import PARALLEL_LIMIT
+from compose.errors import CompletedUnsuccessfully
from compose.errors import HealthCheckFailed
from compose.errors import NoHealthCheckConfigured
from compose.errors import OperationFailedError
@@ -61,7 +62,8 @@ def parallel_execute_watch(events, writer, errors, results, msg, get_name, fail_
elif isinstance(exception, APIError):
errors[get_name(obj)] = exception.explanation
writer.write(msg, get_name(obj), 'error', red)
- elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured)):
+ elif isinstance(exception, (OperationFailedError, HealthCheckFailed, NoHealthCheckConfigured,
+ CompletedUnsuccessfully)):
errors[get_name(obj)] = exception.msg
writer.write(msg, get_name(obj), 'error', red)
elif isinstance(exception, UpstreamError):
@@ -241,6 +243,12 @@ def feed_queue(objects, func, get_deps, results, state, limiter):
'not processing'.format(obj)
)
results.put((obj, None, e))
+ except CompletedUnsuccessfully as e:
+ log.debug(
+ 'Service(s) upstream of {} did not completed successfully - '
+ 'not processing'.format(obj)
+ )
+ results.put((obj, None, e))
if state.is_done():
results.put(STOP)
diff --git a/compose/service.py b/compose/service.py
index df0d76fb..b5ac907a 100644
--- a/compose/service.py
+++ b/compose/service.py
@@ -45,6 +45,7 @@ from .const import LABEL_VERSION
from .const import NANOCPUS_SCALE
from .const import WINDOWS_LONGPATH_PREFIX
from .container import Container
+from .errors import CompletedUnsuccessfully
from .errors import HealthCheckFailed
from .errors import NoHealthCheckConfigured
from .errors import OperationFailedError
@@ -112,6 +113,7 @@ HOST_CONFIG_KEYS = [
CONDITION_STARTED = 'service_started'
CONDITION_HEALTHY = 'service_healthy'
+CONDITION_COMPLETED_SUCCESSFULLY = 'service_completed_successfully'
class BuildError(Exception):
@@ -753,6 +755,8 @@ class Service:
configs[svc] = lambda s: True
elif config['condition'] == CONDITION_HEALTHY:
configs[svc] = lambda s: s.is_healthy()
+ elif config['condition'] == CONDITION_COMPLETED_SUCCESSFULLY:
+ configs[svc] = lambda s: s.is_completed_successfully()
else:
# The config schema already prevents this, but it might be
# bypassed if Compose is called programmatically.
@@ -1304,6 +1308,21 @@ class Service:
raise HealthCheckFailed(ctnr.short_id)
return result
+ def is_completed_successfully(self):
+ """ Check that all containers for this service has completed successfully
+ Returns false if at least one container does not exited and
+ raises CompletedUnsuccessfully exception if at least one container
+ exited with non-zero exit code.
+ """
+ result = True
+ for ctnr in self.containers(stopped=True):
+ ctnr.inspect()
+ if ctnr.get('State.Status') != 'exited':
+ result = False
+ elif ctnr.exit_code != 0:
+ raise CompletedUnsuccessfully(ctnr.short_id, ctnr.exit_code)
+ return result
+
def _parse_proxy_config(self):
client = self.client
if 'proxies' not in client._general_configs:
diff --git a/tests/integration/project_test.py b/tests/integration/project_test.py
index c4210291..fe21c929 100644
--- a/tests/integration/project_test.py
+++ b/tests/integration/project_test.py
@@ -25,6 +25,7 @@ from compose.const import COMPOSE_SPEC as VERSION
from compose.const import LABEL_PROJECT
from compose.const import LABEL_SERVICE
from compose.container import Container
+from compose.errors import CompletedUnsuccessfully
from compose.errors import HealthCheckFailed
from compose.errors import NoHealthCheckConfigured
from compose.project import Project
@@ -1899,6 +1900,110 @@ class ProjectTest(DockerClientTestCase):
with pytest.raises(NoHealthCheckConfigured):
svc1.is_healthy()
+ def test_project_up_completed_successfully_dependency(self):
+ config_dict = {
+ 'version': '2.1',
+ 'services': {
+ 'svc1': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'true'
+ },
+ 'svc2': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'top',
+ 'depends_on': {
+ 'svc1': {'condition': 'service_completed_successfully'},
+ }
+ }
+ }
+ }
+ config_data = load_config(config_dict)
+ project = Project.from_config(
+ name='composetest', config_data=config_data, client=self.client
+ )
+ project.up()
+
+ svc1 = project.get_service('svc1')
+ svc2 = project.get_service('svc2')
+
+ assert 'svc1' in svc2.get_dependency_names()
+ assert svc2.containers()[0].is_running
+ assert len(svc1.containers()) == 0
+ assert svc1.is_completed_successfully()
+
+ def test_project_up_completed_unsuccessfully_dependency(self):
+ config_dict = {
+ 'version': '2.1',
+ 'services': {
+ 'svc1': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'false'
+ },
+ 'svc2': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'top',
+ 'depends_on': {
+ 'svc1': {'condition': 'service_completed_successfully'},
+ }
+ }
+ }
+ }
+ config_data = load_config(config_dict)
+ project = Project.from_config(
+ name='composetest', config_data=config_data, client=self.client
+ )
+ with pytest.raises(ProjectError):
+ project.up()
+
+ containers = project.containers()
+ assert len(containers) == 0
+
+ svc1 = project.get_service('svc1')
+ svc2 = project.get_service('svc2')
+ assert 'svc1' in svc2.get_dependency_names()
+ with pytest.raises(CompletedUnsuccessfully):
+ svc1.is_completed_successfully()
+
+ def test_project_up_completed_differently_dependencies(self):
+ config_dict = {
+ 'version': '2.1',
+ 'services': {
+ 'svc1': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'true'
+ },
+ 'svc2': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'false'
+ },
+ 'svc3': {
+ 'image': BUSYBOX_IMAGE_WITH_TAG,
+ 'command': 'top',
+ 'depends_on': {
+ 'svc1': {'condition': 'service_completed_successfully'},
+ 'svc2': {'condition': 'service_completed_successfully'},
+ }
+ }
+ }
+ }
+ config_data = load_config(config_dict)
+ project = Project.from_config(
+ name='composetest', config_data=config_data, client=self.client
+ )
+ with pytest.raises(ProjectError):
+ project.up()
+
+ containers = project.containers()
+ assert len(containers) == 0
+
+ svc1 = project.get_service('svc1')
+ svc2 = project.get_service('svc2')
+ svc3 = project.get_service('svc3')
+ assert ['svc1', 'svc2'] == svc3.get_dependency_names()
+ assert svc1.is_completed_successfully()
+ with pytest.raises(CompletedUnsuccessfully):
+ svc2.is_completed_successfully()
+
def test_project_up_seccomp_profile(self):
seccomp_data = {
'defaultAction': 'SCMP_ACT_ALLOW',
diff --git a/tests/unit/config/config_test.py b/tests/unit/config/config_test.py
index ffc16e08..1edb15a2 100644
--- a/tests/unit/config/config_test.py
+++ b/tests/unit/config/config_test.py
@@ -2397,7 +2397,8 @@ web:
'image': 'busybox',
'depends_on': {
'app1': {'condition': 'service_started'},
- 'app2': {'condition': 'service_healthy'}
+ 'app2': {'condition': 'service_healthy'},
+ 'app3': {'condition': 'service_completed_successfully'}
}
}
override = {}
@@ -2409,11 +2410,12 @@ web:
'image': 'busybox',
'depends_on': {
'app1': {'condition': 'service_started'},
- 'app2': {'condition': 'service_healthy'}
+ 'app2': {'condition': 'service_healthy'},
+ 'app3': {'condition': 'service_completed_successfully'}
}
}
override = {
- 'depends_on': ['app3']
+ 'depends_on': ['app4']
}
actual = config.merge_service_dicts(base, override, VERSION)
@@ -2422,7 +2424,8 @@ web:
'depends_on': {
'app1': {'condition': 'service_started'},
'app2': {'condition': 'service_healthy'},
- 'app3': {'condition': 'service_started'}
+ 'app3': {'condition': 'service_completed_successfully'},
+ 'app4': {'condition': 'service_started'},
}
}