From 30e5223a28acdc71d12df7a4bf1250722b224a5a Mon Sep 17 00:00:00 2001 From: Adrian Torres Date: Sun, 20 Mar 2022 00:17:41 +0100 Subject: [PATCH 1/3] Track dependency conditions when parsing cnditional depends_on This commit changes the way that depends_on blocks are parsed and tracked during the `podman-compose up` process. Each service's dependencies will be tracked as a dict in which keys are services being depended on and values are the condition to be met by said services before the current service can start. This lays the groundwork for supporting long-syntax / conditional depends_on blocks, but should not change any behavior so far. Signed-off-by: Adrian Torres --- podman_compose.py | 58 +++++++++++++++++++++++++++++----- pytests/test_dependencies.py | 37 ++++++++++++++++++++++ tests/deps/docker-compose.yaml | 24 +++++++++++++- 3 files changed, 110 insertions(+), 9 deletions(-) create mode 100644 pytests/test_dependencies.py diff --git a/podman_compose.py b/podman_compose.py index 54a1ba7f..d67c3ded 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -102,6 +102,27 @@ def strverscmp_lt(a, b): return a_ls < b_ls +class DependsCondition: # pylint: disable=too-few-public-methods + # enum for possible types of depends_on conditions + # see https://github.com/compose-spec/compose-spec/blob/master/spec.md#long-syntax-1 + STARTED = 0 + HEALTHY = 1 + COMPLETED = 2 + + @classmethod + def to_enum(cls, condition): + """ + Converts and returns a condition value into a valid enum value. + """ + if condition == "service_healthy": + return cls.HEALTHY + if condition == "service_completed_successfully": + return cls.COMPLETED + # use cls.STARTED as a catch-all value even + # if the condition value is not within spec + return cls.STARTED + + def parse_short_mount(mount_str, basedir): mount_a = mount_str.split(":") mount_opt_dict = {} @@ -987,25 +1008,46 @@ def flat_deps(services, with_extends=False): create dependencies "_deps" or update it recursively for all services """ for name, srv in services.items(): - deps = set() - srv["_deps"] = deps + deps = {} if with_extends: ext = srv.get("extends", {}).get("service", None) if ext: if ext != name: - deps.add(ext) + deps[ext] = DependsCondition.STARTED continue + # NOTE: important that the get call is kept as-is, since depends_on + # can be an empty string and in that case we want to have an empty list deps_ls = srv.get("depends_on", None) or [] if is_str(deps_ls): - deps_ls = [deps_ls] + # depends_on: "foo" + # treat as condition: service_started + deps_ls = {deps_ls: DependsCondition.STARTED} elif is_dict(deps_ls): - deps_ls = list(deps_ls.keys()) - deps.update(deps_ls) + # depends_on: + # foo: + # condition: service_xxx + tmp = {} + for service, condition in deps_ls.items(): + condition = DependsCondition.to_enum(condition.get("condition")) + tmp[service] = condition + deps_ls = tmp + else: + # depends_on: + # - foo + # treat as condition: service_started + deps_ls = {dep: DependsCondition.STARTED for dep in deps_ls} + deps = {**deps, **deps_ls} # parse link to get service name and remove alias + # NOTE: important that the get call is kept as-is, since links can + # be an empty string and in that case we want to have an empty list links_ls = srv.get("links", None) or [] if not is_list(links_ls): links_ls = [links_ls] - deps.update([(c.split(":")[0] if ":" in c else c) for c in links_ls]) + deps = { + **deps, + **{c.split(":")[0]: DependsCondition.STARTED for c in links_ls}, + } + srv["_deps"] = deps for name, srv in services.items(): rec_deps(services, name) @@ -1922,7 +1964,7 @@ def get_excluded(compose, args): if args.services: excluded = set(compose.services) for service in args.services: - excluded -= compose.services[service]["_deps"] + excluded -= set(compose.services[service]["_deps"].keys()) excluded.discard(service) log("** excluding: ", excluded) return excluded diff --git a/pytests/test_dependencies.py b/pytests/test_dependencies.py new file mode 100644 index 00000000..7f592051 --- /dev/null +++ b/pytests/test_dependencies.py @@ -0,0 +1,37 @@ +import pytest + +from podman_compose import flat_deps, DependsCondition + + +@pytest.fixture +def basic_services(): + return { + "foo": {}, + "bar": { + # string dependency + "depends_on": "foo", + }, + "baz": { + # list dependency + "depends_on": ["bar"], + }, + "ham": { + # dict / conditional dependency + "depends_on": { + "foo": { + "condition": "service_healthy", + }, + }, + }, + } + + +def test_flat_deps(basic_services): + flat_deps(basic_services) + assert basic_services["foo"]["_deps"] == {} + assert basic_services["bar"]["_deps"] == {"foo": DependsCondition.STARTED} + assert basic_services["baz"]["_deps"] == { + "bar": DependsCondition.STARTED, + "foo": DependsCondition.STARTED, + } + assert basic_services["ham"]["_deps"] == {"foo": DependsCondition.HEALTHY} diff --git a/tests/deps/docker-compose.yaml b/tests/deps/docker-compose.yaml index 0f06bbd4..833bd8f9 100644 --- a/tests/deps/docker-compose.yaml +++ b/tests/deps/docker-compose.yaml @@ -21,4 +21,26 @@ services: tmpfs: - /run - /tmp - + hello_world: + image: busybox + command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] + depends_on: + sleep: + condition: service_started + tmpfs: + - /run + - /tmp + healthcheck: + test: echo "hello world" + interval: 10s + timeout: 5s + retries: 5 + hello_world_2: + image: busybox + command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] + depends_on: + sleep: + condition: service_healthy + tmpfs: + - /run + - /tmp From fcbf15e5c65d3b402425ed1fd4e9767ebc001e1b Mon Sep 17 00:00:00 2001 From: Adrian Torres Date: Sun, 20 Mar 2022 18:11:14 +0100 Subject: [PATCH 2/3] Wait for depends_on conditions before starting containers This commit implements the long-syntax / conditional depends_on compose mechanism which instructs the compose system to wait for a certain condition before starting a container. Currently available conditions are: - service_started: same behavior as before this commit, the depending container will start as soon as the depended on container has started - service_healthy: if the depended on container has a healthcheck, wait until said container is marked as healthy before starting the depending container - service_completed_successfully: wait until the depended on container has exited and its exit code is 0, after which the depending container can be started This mechanism is part of the v3 [1] compose spec and is useful for controlling container startup based on other containers that can take a certain amount of time to start or on containers that do complicated setups and must exit before starting other containers. [1] https://red.ht/conditional-depends Signed-off-by: Adrian Torres --- podman_compose.py | 58 ++++++++++++++++++++++++++++++++-- tests/deps/docker-compose.yaml | 46 +++++++++++++++++++++++---- 2 files changed, 95 insertions(+), 9 deletions(-) diff --git a/podman_compose.py b/podman_compose.py index d67c3ded..f917d041 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -123,6 +123,14 @@ def to_enum(cls, condition): return cls.STARTED +def wait(func): + def wrapper(*args, **kwargs): + while not func(*args, **kwargs): + time.sleep(0.5) + + return wrapper + + def parse_short_mount(mount_str, basedir): mount_a = mount_str.split(":") mount_opt_dict = {} @@ -1087,7 +1095,7 @@ def run( podman_args, cmd="", cmd_args=None, - wait=True, + _wait=True, sleep=1, obj=None, log_formatter=None, @@ -1113,7 +1121,7 @@ def run( else: p = subprocess.Popen(cmd_ls) # pylint: disable=consider-using-with - if wait: + if _wait: exit_code = p.wait() log("exit code:", exit_code) if obj is not None: @@ -1970,6 +1978,49 @@ def get_excluded(compose, args): return excluded +@wait +def wait_healthy(compose, container_name): + info = json.loads(compose.podman.output([], "inspect", [container_name]))[0] + + if not info["Config"].get("Healthcheck"): + raise ValueError("Container %s does not define a health check" % container_name) + + health = info["State"]["Healthcheck"]["Status"] + if health == "unhealthy": + raise RuntimeError( + "Container %s is in unhealthy state, aborting" % container_name + ) + return health == "healthy" + + +@wait +def wait_completed(compose, container_name): + info = json.loads(compose.podman.output([], "inspect", [container_name]))[0] + + if info["State"]["Status"] == "exited": + exit_code = info["State"]["ExitCode"] + if exit_code != 0: + raise RuntimeError( + "Container %s didn't complete successfully, exit code: %d" + % (container_name, exit_code) + ) + return True + return False + + +def wait_for_dependencies(compose, container): + for dep, condition in container["_deps"].items(): + dep_container_name = compose.container_names_by_service[dep][0] + if condition == DependsCondition.STARTED: + # ignore -- will be handled by container order + continue + if condition == DependsCondition.HEALTHY: + wait_healthy(compose, dep_container_name) + else: + # implies DependsCondition.COMPLETED + wait_completed(compose, dep_container_name) + + @cmd_run( podman_compose, "up", "Create and start the entire stack or some of its services" ) @@ -2012,6 +2063,8 @@ def compose_up(compose, args): log("** skipping: ", cnt["name"]) continue podman_args = container_to_args(compose, cnt, detached=args.detach) + if podman_command == "run": + wait_for_dependencies(compose, cnt) subproc = compose.podman.run([], podman_command, podman_args) if podman_command == "run" and subproc and subproc.returncode: compose.podman.run([], "start", [cnt["name"]]) @@ -2047,6 +2100,7 @@ def compose_up(compose, args): continue # TODO: remove sleep from podman.run obj = compose if exit_code_from == cnt["_service"] else None + wait_for_dependencies(compose, cnt) thread = Thread( target=compose.podman.run, args=[[], "start", ["-a", cnt["name"]]], diff --git a/tests/deps/docker-compose.yaml b/tests/deps/docker-compose.yaml index 833bd8f9..3f381cf4 100644 --- a/tests/deps/docker-compose.yaml +++ b/tests/deps/docker-compose.yaml @@ -6,6 +6,14 @@ services: tmpfs: - /run - /tmp + healthcheck: + # test that httpd is running, the brackets [] thing is a trick + # to ignore the grep process returned by ps, meaning that this + # should only be true if httpd is currently running + test: ps | grep "[h]ttpd" + interval: 10s + timeout: 5s + retries: 5 sleep: image: busybox command: ["/bin/busybox", "sh", "-c", "sleep 3600"] @@ -13,6 +21,11 @@ services: tmpfs: - /run - /tmp + healthcheck: + test: sleep 15 + interval: 10s + timeout: 20s + retries: 5 sleep2: image: busybox command: ["/bin/busybox", "sh", "-c", "sleep 3600"] @@ -21,7 +34,13 @@ services: tmpfs: - /run - /tmp - hello_world: + setup: + image: busybox + command: ["/bin/busybox", "sh", "-c", "sleep 30"] + tmpfs: + - /run + - /tmp + wait_started: image: busybox command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] depends_on: @@ -30,12 +49,16 @@ services: tmpfs: - /run - /tmp - healthcheck: - test: echo "hello world" - interval: 10s - timeout: 5s - retries: 5 - hello_world_2: + wait_healthy: + image: busybox + command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] + depends_on: + web: + condition: service_healthy + tmpfs: + - /run + - /tmp + wait_multiple_healthchecks: image: busybox command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] depends_on: @@ -44,3 +67,12 @@ services: tmpfs: - /run - /tmp + wait_completed_successfully: + image: busybox + command: ["/bin/busybox", "sh", "-c", "echo 'hello world'"] + depends_on: + setup: + condition: service_completed_successfully + tmpfs: + - /run + - /tmp From dad5d56f33e80b9e0a3dc05da3dce104f7b91d04 Mon Sep 17 00:00:00 2001 From: Adrian Torres Date: Thu, 6 Oct 2022 09:42:53 +0200 Subject: [PATCH 3/3] Update podman_compose.py Co-authored-by: Dawid Dziurla --- podman_compose.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/podman_compose.py b/podman_compose.py index f917d041..78b772a3 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -1985,7 +1985,7 @@ def wait_healthy(compose, container_name): if not info["Config"].get("Healthcheck"): raise ValueError("Container %s does not define a health check" % container_name) - health = info["State"]["Healthcheck"]["Status"] + health = info["State"]["Health"]["Status"] if health == "unhealthy": raise RuntimeError( "Container %s is in unhealthy state, aborting" % container_name