mirror of
https://github.com/containers/podman-compose.git
synced 2025-04-30 20:34:39 +02:00
Adding support for conditional dependencies
Signed-off-by: Felix Rubio <felix@kngnt.org>
This commit is contained in:
parent
3ba0396e7a
commit
a67fa0beb5
1
newsfragments/conditional-dependencies.feature
Normal file
1
newsfragments/conditional-dependencies.feature
Normal file
@ -0,0 +1 @@
|
|||||||
|
Added support for honoring the condition in the depends_on section of the service, if stated.
|
@ -25,6 +25,7 @@ import signal
|
|||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from asyncio import Task
|
from asyncio import Task
|
||||||
|
from enum import Enum
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from shlex import quote as cmd_quote
|
from shlex import quote as cmd_quote
|
||||||
@ -1273,22 +1274,59 @@ async def container_to_args(compose, cnt, detached=True):
|
|||||||
return podman_args
|
return podman_args
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceDependencyCondition(Enum):
|
||||||
|
CONFIGURED = "configured"
|
||||||
|
CREATED = "created"
|
||||||
|
EXITED = "exited"
|
||||||
|
HEALTHY = "healthy"
|
||||||
|
INITIALIZED = "initialized"
|
||||||
|
PAUSED = "paused"
|
||||||
|
REMOVING = "removing"
|
||||||
|
RUNNING = "running"
|
||||||
|
STOPPED = "stopped"
|
||||||
|
STOPPING = "stopping"
|
||||||
|
UNHEALTHY = "unhealthy"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_value(cls, value):
|
||||||
|
# Check if the value exists in the enum
|
||||||
|
for member in cls:
|
||||||
|
if member.value == value:
|
||||||
|
return member
|
||||||
|
|
||||||
|
# Check if this is a value coming from reference
|
||||||
|
docker_to_podman_cond = {
|
||||||
|
"service_healthy": ServiceDependencyCondition.HEALTHY,
|
||||||
|
"service_started": ServiceDependencyCondition.RUNNING,
|
||||||
|
"service_completed_successfully": ServiceDependencyCondition.STOPPED,
|
||||||
|
}
|
||||||
|
try:
|
||||||
|
return docker_to_podman_cond[value]
|
||||||
|
except KeyError:
|
||||||
|
raise ValueError(f"Value '{value}' is not a valid condition for a service dependency") # pylint: disable=raise-missing-from
|
||||||
|
|
||||||
|
|
||||||
class ServiceDependency:
|
class ServiceDependency:
|
||||||
def __init__(self, name):
|
def __init__(self, name, condition):
|
||||||
self._name = name
|
self._name = name
|
||||||
|
self._condition = ServiceDependencyCondition.from_value(condition)
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self._name
|
return self._name
|
||||||
|
|
||||||
|
@property
|
||||||
|
def condition(self):
|
||||||
|
return self._condition
|
||||||
|
|
||||||
def __hash__(self):
|
def __hash__(self):
|
||||||
# Compute hash based on the frozenset of items to ensure order does not matter
|
# Compute hash based on the frozenset of items to ensure order does not matter
|
||||||
return hash(('name', self._name))
|
return hash(('name', self._name) + ('condition', self._condition))
|
||||||
|
|
||||||
def __eq__(self, other):
|
def __eq__(self, other):
|
||||||
# Compare equality based on dictionary content
|
# Compare equality based on dictionary content
|
||||||
if isinstance(other, ServiceDependency):
|
if isinstance(other, ServiceDependency):
|
||||||
return self._name == other.name
|
return self._name == other.name and self._condition == other.condition
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
@ -1319,31 +1357,35 @@ def flat_deps(services, with_extends=False):
|
|||||||
create dependencies "_deps" or update it recursively for all services
|
create dependencies "_deps" or update it recursively for all services
|
||||||
"""
|
"""
|
||||||
for name, srv in services.items():
|
for name, srv in services.items():
|
||||||
|
# parse dependencies for each service
|
||||||
deps = set()
|
deps = set()
|
||||||
srv["_deps"] = deps
|
srv["_deps"] = deps
|
||||||
|
# TODO: manage properly the dependencies coming from base services when extended
|
||||||
if with_extends:
|
if with_extends:
|
||||||
ext = srv.get("extends", {}).get("service", None)
|
ext = srv.get("extends", {}).get("service", None)
|
||||||
if ext:
|
if ext:
|
||||||
if ext != name:
|
if ext != name:
|
||||||
deps.add(ServiceDependency(ext))
|
deps.add(ServiceDependency(ext, "service_started"))
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# the compose file has been normalized. depends_on, if exists, can only be a dictionary
|
# the compose file has been normalized. depends_on, if exists, can only be a dictionary
|
||||||
# the normalization adds a "service_started" condition by default
|
# the normalization adds a "service_started" condition by default
|
||||||
deps_ls = srv.get("depends_on", {})
|
deps_ls = srv.get("depends_on", {})
|
||||||
deps_ls = [ServiceDependency(k) for k, v in deps_ls.items()]
|
deps_ls = [ServiceDependency(k, v["condition"]) for k, v in deps_ls.items()]
|
||||||
deps.update(deps_ls)
|
deps.update(deps_ls)
|
||||||
# parse link to get service name and remove alias
|
# parse link to get service name and remove alias
|
||||||
links_ls = srv.get("links", None) or []
|
links_ls = srv.get("links", None) or []
|
||||||
if not is_list(links_ls):
|
if not is_list(links_ls):
|
||||||
links_ls = [links_ls]
|
links_ls = [links_ls]
|
||||||
deps.update([ServiceDependency(c.split(":")[0]) for c in links_ls])
|
deps.update([ServiceDependency(c.split(":")[0], "service_started") for c in links_ls])
|
||||||
for c in links_ls:
|
for c in links_ls:
|
||||||
if ":" in c:
|
if ":" in c:
|
||||||
dep_name, dep_alias = c.split(":")
|
dep_name, dep_alias = c.split(":")
|
||||||
if "_aliases" not in services[dep_name]:
|
if "_aliases" not in services[dep_name]:
|
||||||
services[dep_name]["_aliases"] = set()
|
services[dep_name]["_aliases"] = set()
|
||||||
services[dep_name]["_aliases"].add(dep_alias)
|
services[dep_name]["_aliases"].add(dep_alias)
|
||||||
|
|
||||||
|
# expand the dependencies on each service
|
||||||
for name, srv in services.items():
|
for name, srv in services.items():
|
||||||
rec_deps(services, name)
|
rec_deps(services, name)
|
||||||
|
|
||||||
@ -2525,11 +2567,54 @@ def get_excluded(compose, args):
|
|||||||
return excluded
|
return excluded
|
||||||
|
|
||||||
|
|
||||||
|
async def check_dep_conditions(compose: PodmanCompose, deps: set) -> None:
|
||||||
|
"""Enforce that all specified conditions in deps are met"""
|
||||||
|
if not deps:
|
||||||
|
return
|
||||||
|
|
||||||
|
for condition in ServiceDependencyCondition:
|
||||||
|
deps_cd = []
|
||||||
|
for d in deps:
|
||||||
|
if d.condition == condition:
|
||||||
|
deps_cd.extend(compose.container_names_by_service[d.name])
|
||||||
|
|
||||||
|
if deps_cd:
|
||||||
|
# podman wait will return always with a rc -1.
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
await compose.podman.output(
|
||||||
|
[], "wait", [f"--condition={condition.value}"] + deps_cd
|
||||||
|
)
|
||||||
|
log.debug(
|
||||||
|
"dependencies for condition %s have been fulfilled on containers %s",
|
||||||
|
condition.value,
|
||||||
|
', '.join(deps_cd),
|
||||||
|
)
|
||||||
|
break
|
||||||
|
except subprocess.CalledProcessError as _exc:
|
||||||
|
output = list(
|
||||||
|
((_exc.stdout or b"") + (_exc.stderr or b"")).decode().split('\n')
|
||||||
|
)
|
||||||
|
log.debug(
|
||||||
|
'Podman wait returned an error (%d) when executing "%s": %s',
|
||||||
|
_exc.returncode,
|
||||||
|
_exc.cmd,
|
||||||
|
output,
|
||||||
|
)
|
||||||
|
await asyncio.sleep(1)
|
||||||
|
|
||||||
|
|
||||||
async def run_container(
|
async def run_container(
|
||||||
compose: PodmanCompose, name: str, command: tuple, log_formatter: str = None
|
compose: PodmanCompose, name: str, deps: set, command: tuple, log_formatter: str = None
|
||||||
):
|
):
|
||||||
"""runs a container after waiting for its dependencies to be fulfilled"""
|
"""runs a container after waiting for its dependencies to be fulfilled"""
|
||||||
|
|
||||||
|
# wait for the dependencies to be fulfilled
|
||||||
|
if "start" in command:
|
||||||
|
log.debug("Checking dependencies prior to container %s start", name)
|
||||||
|
await check_dep_conditions(compose, deps)
|
||||||
|
|
||||||
|
# start the container
|
||||||
log.debug("Starting task for container %s", name)
|
log.debug("Starting task for container %s", name)
|
||||||
return await compose.podman.run(*command, log_formatter=log_formatter)
|
return await compose.podman.run(*command, log_formatter=log_formatter)
|
||||||
|
|
||||||
@ -2578,7 +2663,7 @@ async def compose_up(compose: PodmanCompose, args):
|
|||||||
podman_args = await container_to_args(compose, cnt, detached=args.detach)
|
podman_args = await container_to_args(compose, cnt, detached=args.detach)
|
||||||
subproc = await compose.podman.run([], podman_command, podman_args)
|
subproc = await compose.podman.run([], podman_command, podman_args)
|
||||||
if podman_command == "run" and subproc is not None:
|
if podman_command == "run" and subproc is not None:
|
||||||
await run_container(compose, cnt["name"], ([], "start", [cnt["name"]]))
|
await run_container(compose, cnt["name"], cnt["_deps"], ([], "start", [cnt["name"]]))
|
||||||
if args.no_start or args.detach or args.dry_run:
|
if args.no_start or args.detach or args.dry_run:
|
||||||
return
|
return
|
||||||
# TODO: handle already existing
|
# TODO: handle already existing
|
||||||
@ -2613,6 +2698,7 @@ async def compose_up(compose: PodmanCompose, args):
|
|||||||
run_container(
|
run_container(
|
||||||
compose,
|
compose,
|
||||||
cnt["name"],
|
cnt["name"],
|
||||||
|
cnt["_deps"],
|
||||||
([], "start", ["-a", cnt["name"]]),
|
([], "start", ["-a", cnt["name"]]),
|
||||||
log_formatter=log_formatter,
|
log_formatter=log_formatter,
|
||||||
),
|
),
|
||||||
|
22
tests/integration/deps/docker-compose-conditional-fails.yaml
Normal file
22
tests/integration/deps/docker-compose-conditional-fails.yaml
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
version: "3.7"
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nopush/podman-compose-test
|
||||||
|
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "/bin/false"]
|
||||||
|
interval: 10s # Time between health checks
|
||||||
|
timeout: 1s # Time to wait for a response
|
||||||
|
retries: 1 # Number of consecutive failures before marking as unhealthy
|
||||||
|
sleep:
|
||||||
|
image: nopush/podman-compose-test
|
||||||
|
command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"]
|
||||||
|
depends_on:
|
||||||
|
web:
|
||||||
|
condition: service_healthy
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
- /tmp
|
@ -0,0 +1,22 @@
|
|||||||
|
version: "3.7"
|
||||||
|
services:
|
||||||
|
web:
|
||||||
|
image: nopush/podman-compose-test
|
||||||
|
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
- /tmp
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "-qO-", "http://localhost:8000/hosts"]
|
||||||
|
interval: 30s # Time between health checks
|
||||||
|
timeout: 5s # Time to wait for a response
|
||||||
|
retries: 3 # Number of consecutive failures before marking as unhealthy
|
||||||
|
sleep:
|
||||||
|
image: nopush/podman-compose-test
|
||||||
|
command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"]
|
||||||
|
depends_on:
|
||||||
|
web:
|
||||||
|
condition: service_healthy
|
||||||
|
tmpfs:
|
||||||
|
- /run
|
||||||
|
- /tmp
|
@ -7,11 +7,11 @@ from tests.integration.test_podman_compose import test_path
|
|||||||
from tests.integration.test_utils import RunSubprocessMixin
|
from tests.integration.test_utils import RunSubprocessMixin
|
||||||
|
|
||||||
|
|
||||||
def compose_yaml_path():
|
def compose_yaml_path(suffix=""):
|
||||||
return os.path.join(os.path.join(test_path(), "deps"), "docker-compose.yaml")
|
return os.path.join(os.path.join(test_path(), "deps"), f"docker-compose{suffix}.yaml")
|
||||||
|
|
||||||
|
|
||||||
class TestComposeDeps(unittest.TestCase, RunSubprocessMixin):
|
class TestComposeBaseDeps(unittest.TestCase, RunSubprocessMixin):
|
||||||
def test_deps(self):
|
def test_deps(self):
|
||||||
try:
|
try:
|
||||||
output, error = self.run_subprocess_assert_returncode([
|
output, error = self.run_subprocess_assert_returncode([
|
||||||
@ -34,3 +34,48 @@ class TestComposeDeps(unittest.TestCase, RunSubprocessMixin):
|
|||||||
compose_yaml_path(),
|
compose_yaml_path(),
|
||||||
"down",
|
"down",
|
||||||
])
|
])
|
||||||
|
|
||||||
|
|
||||||
|
class TestComposeConditionalDeps(unittest.TestCase, RunSubprocessMixin):
|
||||||
|
def test_deps_succeeds(self):
|
||||||
|
suffix = "-conditional-succeeds"
|
||||||
|
try:
|
||||||
|
output, error = self.run_subprocess_assert_returncode([
|
||||||
|
podman_compose_path(),
|
||||||
|
"-f",
|
||||||
|
compose_yaml_path(suffix),
|
||||||
|
"run",
|
||||||
|
"--rm",
|
||||||
|
"sleep",
|
||||||
|
"/bin/sh",
|
||||||
|
"-c",
|
||||||
|
"wget -O - http://web:8000/hosts",
|
||||||
|
])
|
||||||
|
self.assertIn(b"HTTP request sent, awaiting response... 200 OK", output)
|
||||||
|
self.assertIn(b"deps_web_1", output)
|
||||||
|
finally:
|
||||||
|
self.run_subprocess_assert_returncode([
|
||||||
|
podman_compose_path(),
|
||||||
|
"-f",
|
||||||
|
compose_yaml_path(suffix),
|
||||||
|
"down",
|
||||||
|
])
|
||||||
|
|
||||||
|
def test_deps_fails(self):
|
||||||
|
suffix = "-conditional-fails"
|
||||||
|
try:
|
||||||
|
output, error = self.run_subprocess_assert_returncode([
|
||||||
|
podman_compose_path(),
|
||||||
|
"-f",
|
||||||
|
compose_yaml_path(suffix),
|
||||||
|
"ps",
|
||||||
|
])
|
||||||
|
self.assertNotIn(b"HTTP request sent, awaiting response... 200 OK", output)
|
||||||
|
self.assertNotIn(b"deps_web_1", output)
|
||||||
|
finally:
|
||||||
|
self.run_subprocess_assert_returncode([
|
||||||
|
podman_compose_path(),
|
||||||
|
"-f",
|
||||||
|
compose_yaml_path(suffix),
|
||||||
|
"down",
|
||||||
|
])
|
||||||
|
Loading…
Reference in New Issue
Block a user