diff --git a/compose/config/compose_spec.json b/compose/config/compose_spec.json index 8eadb8f2f..86e3de1ee 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 d4fead251..502b64b89 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 74d3e3c05..316e2217a 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 fda1edb2e..40edbf68f 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): @@ -771,6 +773,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. @@ -1322,6 +1326,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 c4210291f..fe21c9296 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 60b82dcf2..72e39e4f2 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'}, } }