Skip to content

Commit ddd4b8a

Browse files
committed
Fix #1183 to enforce service_healthy condition
- Change compose-up to create then start container to avoid double-execution and enforce dependency condition check - Skip dependency health check to avoid compose-up hang for podman prior to 4.6.0, which doesn't support --condition healthy - Skip running compose-down when there are no active containers - Add relevant integration test case Signed-off-by: Justin Zhang <[email protected]>
1 parent 2e46ff0 commit ddd4b8a

10 files changed

+188
-34
lines changed

newsfragments/1184.bugfix

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
- Fixed issue in up command where service_healthy conditions weren't being enforced (#1183)
2+
- Fixed issue where short-lived containers would execute twice when using the up command in detached mode (#1176)
3+
- Fixed up command hangs on Podman versions earlier than 4.6.0 (#1178)

podman_compose.py

+17-8
Original file line numberDiff line numberDiff line change
@@ -2708,6 +2708,18 @@ async def check_dep_conditions(compose: PodmanCompose, deps: set) -> None:
27082708
deps_cd = []
27092709
for d in deps:
27102710
if d.condition == condition:
2711+
if (
2712+
d.condition
2713+
in (ServiceDependencyCondition.HEALTHY, ServiceDependencyCondition.UNHEALTHY)
2714+
) and strverscmp_lt(compose.podman_version, "4.6.0"):
2715+
log.warning(
2716+
"Ignored %s condition check due to podman %s doesn't support %s!",
2717+
d.name,
2718+
compose.podman_version,
2719+
condition.value,
2720+
)
2721+
continue
2722+
27112723
deps_cd.extend(compose.container_names_by_service[d.name])
27122724

27132725
if deps_cd:
@@ -2784,28 +2796,25 @@ async def compose_up(compose: PodmanCompose, args):
27842796
.splitlines()
27852797
)
27862798
diff_hashes = [i for i in hashes if i and i != compose.yaml_hash]
2787-
if args.force_recreate or len(diff_hashes):
2799+
if (args.force_recreate and len(hashes) > 0) or len(diff_hashes):
27882800
log.info("recreating: ...")
27892801
down_args = argparse.Namespace(**dict(args.__dict__, volumes=False))
27902802
await compose.commands["down"](compose, down_args)
27912803
log.info("recreating: done\n\n")
27922804
# args.no_recreate disables check for changes (which is not implemented)
27932805

2794-
podman_command = "run" if args.detach and not args.no_start else "create"
2795-
27962806
await create_pods(compose, args)
27972807
for cnt in compose.containers:
27982808
if cnt["_service"] in excluded:
27992809
log.debug("** skipping: %s", cnt["name"])
28002810
continue
2801-
podman_args = await container_to_args(
2802-
compose, cnt, detached=args.detach, no_deps=args.no_deps
2803-
)
2804-
subproc = await compose.podman.run([], podman_command, podman_args)
2805-
if podman_command == "run" and subproc is not None:
2811+
podman_args = await container_to_args(compose, cnt, detached=False, no_deps=args.no_deps)
2812+
subproc = await compose.podman.run([], "create", podman_args)
2813+
if not args.no_start and args.detach and subproc is not None:
28062814
await run_container(
28072815
compose, cnt["name"], deps_from_container(args, cnt), ([], "start", [cnt["name"]])
28082816
)
2817+
28092818
if args.no_start or args.detach or args.dry_run:
28102819
return
28112820
# TODO: handle already existing
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
version: "3.7"
2+
services:
3+
web:
4+
image: nopush/podman-compose-test
5+
command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-h", "/etc/", "-p", "8000"]
6+
tmpfs:
7+
- /run
8+
- /tmp
9+
healthcheck:
10+
test: ["CMD", "wget", "-qO-", "http://localhost:8000/hosts"]
11+
start_period: 10s # initialization time for containers that need time to bootstrap
12+
interval: 10s # Time between health checks
13+
timeout: 5s # Time to wait for a response
14+
retries: 3 # Number of consecutive failures before marking as unhealthy
15+
sleep:
16+
image: nopush/podman-compose-test
17+
command: ["dumb-init", "/bin/busybox", "sh", "-c", "sleep 3600"]
18+
depends_on:
19+
web:
20+
condition: service_healthy
21+
tmpfs:
22+
- /run
23+
- /tmp

tests/integration/deps/test_podman_compose_deps.py

+86-5
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@
22
import os
33
import unittest
44

5+
from tests.integration.test_utils import PodmanAwareRunSubprocessMixin
56
from tests.integration.test_utils import RunSubprocessMixin
7+
from tests.integration.test_utils import is_systemd_available
68
from tests.integration.test_utils import podman_compose_path
79
from tests.integration.test_utils import test_path
810

@@ -14,7 +16,7 @@ def compose_yaml_path(suffix=""):
1416
class TestComposeBaseDeps(unittest.TestCase, RunSubprocessMixin):
1517
def test_deps(self):
1618
try:
17-
output, error = self.run_subprocess_assert_returncode([
19+
output, _ = self.run_subprocess_assert_returncode([
1820
podman_compose_path(),
1921
"-f",
2022
compose_yaml_path(),
@@ -37,7 +39,7 @@ def test_deps(self):
3739

3840
def test_run_nodeps(self):
3941
try:
40-
output, error = self.run_subprocess_assert_returncode([
42+
output, _ = self.run_subprocess_assert_returncode([
4143
podman_compose_path(),
4244
"-f",
4345
compose_yaml_path(),
@@ -71,7 +73,7 @@ def test_up_nodeps(self):
7173
"--detach",
7274
"sleep",
7375
])
74-
output, error = self.run_subprocess_assert_returncode([
76+
output, _ = self.run_subprocess_assert_returncode([
7577
podman_compose_path(),
7678
"-f",
7779
compose_yaml_path(),
@@ -144,7 +146,7 @@ class TestComposeConditionalDeps(unittest.TestCase, RunSubprocessMixin):
144146
def test_deps_succeeds(self):
145147
suffix = "-conditional-succeeds"
146148
try:
147-
output, error = self.run_subprocess_assert_returncode([
149+
output, _ = self.run_subprocess_assert_returncode([
148150
podman_compose_path(),
149151
"-f",
150152
compose_yaml_path(suffix),
@@ -168,7 +170,7 @@ def test_deps_succeeds(self):
168170
def test_deps_fails(self):
169171
suffix = "-conditional-fails"
170172
try:
171-
output, error = self.run_subprocess_assert_returncode([
173+
output, _ = self.run_subprocess_assert_returncode([
172174
podman_compose_path(),
173175
"-f",
174176
compose_yaml_path(suffix),
@@ -183,3 +185,82 @@ def test_deps_fails(self):
183185
compose_yaml_path(suffix),
184186
"down",
185187
])
188+
189+
190+
class TestComposeConditionalDepsHealthy(unittest.TestCase, PodmanAwareRunSubprocessMixin):
191+
def setUp(self):
192+
self.podman_version = self.retrieve_podman_version()
193+
194+
def test_up_deps_healthy(self):
195+
suffix = "-conditional-healthy"
196+
try:
197+
self.run_subprocess_assert_returncode([
198+
podman_compose_path(),
199+
"-f",
200+
compose_yaml_path(suffix),
201+
"up",
202+
"sleep",
203+
"--detach",
204+
])
205+
206+
# Since the command `podman wait --condition=healthy` is invalid prior to 4.6.0,
207+
# we only validate healthy status for podman 4.6.0+, which won't be tested in the
208+
# CI pipeline of the podman-compose project where podman 4.3.1 is employed.
209+
podman_ver_major, podman_ver_minor, podman_ver_patch = self.podman_version
210+
if podman_ver_major >= 4 and podman_ver_minor >= 6 and podman_ver_patch >= 0:
211+
self.run_subprocess_assert_returncode([
212+
"podman",
213+
"wait",
214+
"--condition=running",
215+
"deps_web_1",
216+
"deps_sleep_1",
217+
])
218+
219+
# check both web and sleep are running
220+
output, _ = self.run_subprocess_assert_returncode([
221+
podman_compose_path(),
222+
"-f",
223+
compose_yaml_path(),
224+
"ps",
225+
"--format",
226+
"{{.ID}}\t{{.Names}}\t{{.Status}}\t{{.StartedAt}}",
227+
])
228+
229+
# extract container id of web
230+
decoded_out = output.decode('utf-8')
231+
lines = decoded_out.split("\n")
232+
233+
web_lines = [line for line in lines if "web" in line]
234+
self.assertTrue(web_lines)
235+
self.assertEqual(1, len(web_lines))
236+
web_cnt_id, web_cnt_name, web_cnt_status, web_cnt_started = web_lines[0].split("\t")
237+
self.assertNotEqual("", web_cnt_id)
238+
self.assertEqual("deps_web_1", web_cnt_name)
239+
240+
sleep_lines = [line for line in lines if "sleep" in line]
241+
self.assertTrue(sleep_lines)
242+
self.assertEqual(1, len(sleep_lines))
243+
sleep_cnt_id, sleep_cnt_name, _, sleep_cnt_started = sleep_lines[0].split("\t")
244+
self.assertNotEqual("", sleep_cnt_id)
245+
self.assertEqual("deps_sleep_1", sleep_cnt_name)
246+
247+
# When test case is executed inside container like github actions, the absence of
248+
# systemd prevents health check from working properly, resulting in failure to
249+
# transit to healthy state. As a result, we only assert the `healthy` state where
250+
# systemd is functioning.
251+
if (
252+
is_systemd_available()
253+
and podman_ver_major >= 4
254+
and podman_ver_minor >= 6
255+
and podman_ver_patch >= 0
256+
):
257+
self.assertIn("healthy", web_cnt_status)
258+
self.assertGreaterEqual(int(sleep_cnt_started), int(web_cnt_started))
259+
260+
finally:
261+
self.run_subprocess_assert_returncode([
262+
podman_compose_path(),
263+
"-f",
264+
compose_yaml_path(),
265+
"down",
266+
])

tests/integration/extends/test_podman_compose_extends.py

+11-4
Original file line numberDiff line numberDiff line change
@@ -80,18 +80,25 @@ def test_extends_service_launch_env1(self):
8080
"env1",
8181
])
8282
lines = output.decode('utf-8').split('\n')
83-
# HOSTNAME name is random string so is ignored in asserting
84-
lines = sorted([line for line in lines if not line.startswith("HOSTNAME")])
83+
# Test selected env variables to improve robustness
84+
lines = sorted([
85+
line
86+
for line in lines
87+
if line.startswith("BAR")
88+
or line.startswith("BAZ")
89+
or line.startswith("FOO")
90+
or line.startswith("HOME")
91+
or line.startswith("PATH")
92+
or line.startswith("container")
93+
])
8594
self.assertEqual(
8695
lines,
8796
[
88-
'',
8997
'BAR=local',
9098
'BAZ=local',
9199
'FOO=original',
92100
'HOME=/root',
93101
'PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin',
94-
'TERM=xterm',
95102
'container=podman',
96103
],
97104
)

tests/integration/filesystem/test_podman_compose_filesystem.py

+1-2
Original file line numberDiff line numberDiff line change
@@ -35,8 +35,7 @@ def test_compose_symlink(self):
3535
"container1",
3636
])
3737

38-
# BUG: figure out why cat is called twice
39-
self.assertEqual(out, b'data_compose_symlink\ndata_compose_symlink\n')
38+
self.assertEqual(out, b'data_compose_symlink\n')
4039

4140
finally:
4241
out, _ = self.run_subprocess_assert_returncode([

tests/integration/nets_test1/test_podman_compose_nets_test1.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,13 @@ def test_nets_test1(self):
5959
)
6060

6161
# check if Host port is the same as provided by the service port
62+
self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None))
63+
self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0)
64+
self.assertIsNotNone(
65+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None)
66+
)
6267
self.assertEqual(
63-
container_info['NetworkSettings']["Ports"],
64-
{"8001/tcp": [{"HostIp": "", "HostPort": "8001"}]},
68+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8001"
6569
)
6670

6771
self.assertEqual(container_info["Config"]["Hostname"], "web1")
@@ -77,9 +81,13 @@ def test_nets_test1(self):
7781
list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test1_default"
7882
)
7983

84+
self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None))
85+
self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0)
86+
self.assertIsNotNone(
87+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None)
88+
)
8089
self.assertEqual(
81-
container_info['NetworkSettings']["Ports"],
82-
{"8001/tcp": [{"HostIp": "", "HostPort": "8002"}]},
90+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8002"
8391
)
8492

8593
self.assertEqual(container_info["Config"]["Hostname"], "web2")

tests/integration/nets_test2/test_podman_compose_nets_test2.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,13 @@ def test_nets_test2(self):
5959
)
6060

6161
# check if Host port is the same as prodvided by the service port
62+
self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None))
63+
self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0)
64+
self.assertIsNotNone(
65+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None)
66+
)
6267
self.assertEqual(
63-
container_info['NetworkSettings']["Ports"],
64-
{"8001/tcp": [{"HostIp": "", "HostPort": "8001"}]},
68+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8001"
6569
)
6670

6771
self.assertEqual(container_info["Config"]["Hostname"], "web1")
@@ -78,9 +82,13 @@ def test_nets_test2(self):
7882
list(container_info["NetworkSettings"]["Networks"].keys())[0], "nets_test2_mystack"
7983
)
8084

85+
self.assertIsNotNone(container_info['NetworkSettings']["Ports"].get("8001/tcp", None))
86+
self.assertGreater(len(container_info['NetworkSettings']["Ports"]["8001/tcp"]), 0)
87+
self.assertIsNotNone(
88+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0].get("HostPort", None)
89+
)
8190
self.assertEqual(
82-
container_info['NetworkSettings']["Ports"],
83-
{"8001/tcp": [{"HostIp": "", "HostPort": "8002"}]},
91+
container_info['NetworkSettings']["Ports"]["8001/tcp"][0]["HostPort"], "8002"
8492
)
8593

8694
self.assertEqual(container_info["Config"]["Hostname"], "web2")

tests/integration/test_utils.py

+21
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
# SPDX-License-Identifier: GPL-2.0
22

33
import os
4+
import re
45
import subprocess
56
import time
67
from pathlib import Path
@@ -21,6 +22,14 @@ def podman_compose_path():
2122
return os.path.join(base_path(), "podman_compose.py")
2223

2324

25+
def is_systemd_available():
26+
try:
27+
with open("/proc/1/comm", "r", encoding="utf-8") as fh:
28+
return fh.read().strip() == "systemd"
29+
except FileNotFoundError:
30+
return False
31+
32+
2433
class RunSubprocessMixin:
2534
def is_debug_enabled(self):
2635
return "TESTS_DEBUG" in os.environ
@@ -52,3 +61,15 @@ def run_subprocess_assert_returncode(self, args, expected_returncode=0):
5261
f"stdout: {decoded_out}\nstderr: {decoded_err}\n",
5362
)
5463
return out, err
64+
65+
66+
class PodmanAwareRunSubprocessMixin(RunSubprocessMixin):
67+
def retrieve_podman_version(self):
68+
out, _ = self.run_subprocess_assert_returncode(["podman", "--version"])
69+
matcher = re.match(r"\D*(\d+)\.(\d+)\.(\d+)", out.decode('utf-8'))
70+
if matcher:
71+
major = int(matcher.group(1))
72+
minor = int(matcher.group(2))
73+
patch = int(matcher.group(3))
74+
return (major, minor, patch)
75+
raise RuntimeError("Unable to retrieve podman version")

tests/integration/ulimit/test_podman_compose_ulimit.py

+2-7
Original file line numberDiff line numberDiff line change
@@ -34,14 +34,11 @@ def test_ulimit(self):
3434
for el in split_output
3535
if not el.startswith(b"soft process") and not el.startswith(b"hard process")
3636
]
37-
# BUG: figure out why echo is called twice
3837
self.assertEqual(
3938
output_part,
4039
[
4140
b"soft nofile limit 1001",
4241
b"hard nofile limit 1001",
43-
b"soft nofile limit 1001",
44-
b"hard nofile limit 1001",
4542
],
4643
)
4744

@@ -53,8 +50,7 @@ def test_ulimit(self):
5350
self.assertEqual(
5451
out,
5552
b"soft process limit 1002\nhard process limit 2002\nsoft nofile limit 1002\n"
56-
b"hard nofile limit 1002\nsoft process limit 1002\nhard process limit 2002\n"
57-
b"soft nofile limit 1002\nhard nofile limit 1002\n",
53+
b"hard nofile limit 1002\n",
5854
)
5955

6056
out, _ = self.run_subprocess_assert_returncode([
@@ -65,8 +61,7 @@ def test_ulimit(self):
6561
self.assertEqual(
6662
out,
6763
b"soft process limit 1003\nhard process limit 2003\nsoft nofile limit 1003\n"
68-
b"hard nofile limit 1003\nsoft process limit 1003\nhard process limit 2003\n"
69-
b"soft nofile limit 1003\nhard nofile limit 1003\n",
64+
b"hard nofile limit 1003\n",
7065
)
7166
finally:
7267
self.run_subprocess_assert_returncode([

0 commit comments

Comments
 (0)