Procházet zdrojové kódy

Add init container support

Fixes #6855

See https://github.com/compose-spec/compose-spec/pull/134

Signed-off-by: Slava Kardakov <[email protected]>
Slava Kardakov před 4 roky
rodič
revize
4daad056c4

+ 1 - 1
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"]

+ 5 - 0
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)

+ 9 - 1
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)

+ 19 - 0
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:

+ 105 - 0
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',

+ 7 - 4
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'},
             }
         }