From 3e4ef0920893fb24b7940ad3947de41085866425 Mon Sep 17 00:00:00 2001 From: Henrik Schmidt Date: Wed, 19 Nov 2025 14:01:08 +0100 Subject: [PATCH] Fix service_completed_successfully to require exit code 0 Previously accepted any container stop, now correctly validates exit code 0 matching Docker Compose behavior. Docker Compose implementation reference: https://github.com/docker/compose/blob/v2.29.7/pkg/compose/convergence.go#L433 Signed-off-by: Henrik Schmidt --- podman_compose.py | 20 ++++++ ...-compose-conditional-completed-failed.yaml | 18 ++++++ .../docker-compose-conditional-completed.yaml | 18 ++++++ .../deps/test_podman_compose_deps.py | 63 +++++++++++++++++++ .../unit/test_service_dependency_condition.py | 35 +++++++++++ 5 files changed, 154 insertions(+) create mode 100644 tests/integration/deps/docker-compose-conditional-completed-failed.yaml create mode 100644 tests/integration/deps/docker-compose-conditional-completed.yaml create mode 100644 tests/unit/test_service_dependency_condition.py diff --git a/podman_compose.py b/podman_compose.py index 46725ffd..8c715cb5 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -3123,6 +3123,21 @@ def get_excluded( return excluded +async def _validate_completed_successfully(compose: PodmanCompose, container_names: list) -> None: + """Validate that containers exited with code 0 for service_completed_successfully""" + for container_name in container_names: + inspect_output = await compose.podman.output([], "inspect", [container_name]) + container_info = json.loads(inspect_output)[0] + + exit_code = container_info.get("State", {}).get("ExitCode", -1) + if exit_code != 0: + error_msg = ( + f"Container {container_name} didn't complete successfully: exit code {exit_code}" + ) + log.error(error_msg) + raise RuntimeError(error_msg) + + async def check_dep_conditions(compose: PodmanCompose, deps: set) -> None: """Enforce that all specified conditions in deps are met""" if not deps: @@ -3156,6 +3171,11 @@ async def check_dep_conditions(compose: PodmanCompose, deps: set) -> None: await compose.podman.output( [], "wait", [f"--condition={condition.value}"] + deps_cd ) + + # service_completed_successfully requires exit code 0 + if condition == ServiceDependencyCondition.STOPPED: + await _validate_completed_successfully(compose, deps_cd) + log.debug( "dependencies for condition %s have been fulfilled on containers %s", condition.value, diff --git a/tests/integration/deps/docker-compose-conditional-completed-failed.yaml b/tests/integration/deps/docker-compose-conditional-completed-failed.yaml new file mode 100644 index 00000000..fe3333e0 --- /dev/null +++ b/tests/integration/deps/docker-compose-conditional-completed-failed.yaml @@ -0,0 +1,18 @@ +version: "3.7" +services: + failing_oneshot: + image: nopush/podman-compose-test + command: ["sh", "-c", "echo 'Task failed!' && exit 1"] + tmpfs: + - /run + - /tmp + + should_not_start: + image: nopush/podman-compose-test + command: ["sh", "-c", "echo 'This should not run' && sleep 3600"] + depends_on: + failing_oneshot: + condition: service_completed_successfully + tmpfs: + - /run + - /tmp \ No newline at end of file diff --git a/tests/integration/deps/docker-compose-conditional-completed.yaml b/tests/integration/deps/docker-compose-conditional-completed.yaml new file mode 100644 index 00000000..2b5528c9 --- /dev/null +++ b/tests/integration/deps/docker-compose-conditional-completed.yaml @@ -0,0 +1,18 @@ +version: "3.7" +services: + oneshot: + image: nopush/podman-compose-test + command: ["sh", "-c", "echo 'Task completed successfully' && exit 0"] + tmpfs: + - /run + - /tmp + + longrunning: + image: nopush/podman-compose-test + command: ["sh", "-c", "echo 'Starting after oneshot completes' && sleep 3600"] + depends_on: + oneshot: + condition: service_completed_successfully + tmpfs: + - /run + - /tmp \ No newline at end of file diff --git a/tests/integration/deps/test_podman_compose_deps.py b/tests/integration/deps/test_podman_compose_deps.py index 6ea11dc0..07043a36 100644 --- a/tests/integration/deps/test_podman_compose_deps.py +++ b/tests/integration/deps/test_podman_compose_deps.py @@ -186,6 +186,69 @@ def test_deps_fails(self) -> None: "down", ]) + def test_deps_completed_successfully(self) -> None: + suffix = "-conditional-completed" + try: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "up", + "-d", + ]) + + output, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "ps", + ]) + + self.assertIn(b"oneshot", output) + self.assertIn(b"Exited (0)", output) + self.assertIn(b"longrunning", output) + self.assertIn(b"Up", output) + + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "down", + ]) + + def test_deps_completed_failed(self) -> None: + suffix = "-conditional-completed-failed" + try: + output, stderr, returncode = self.run_subprocess([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "up", + "-d", + ]) + + self.assertNotEqual(returncode, 0) + self.assertIn(b"didn't complete successfully", stderr) + + output, _ = self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "ps", + ]) + + self.assertIn(b"failing_oneshot", output) + self.assertIn(b"Exited (1)", output) + + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(suffix), + "down", + ]) + class TestComposeConditionalDepsHealthy(unittest.TestCase, PodmanAwareRunSubprocessMixin): def setUp(self) -> None: diff --git a/tests/unit/test_service_dependency_condition.py b/tests/unit/test_service_dependency_condition.py new file mode 100644 index 00000000..52712a75 --- /dev/null +++ b/tests/unit/test_service_dependency_condition.py @@ -0,0 +1,35 @@ +import unittest + +from podman_compose import ServiceDependencyCondition + + +class TestServiceDependencyCondition(unittest.TestCase): + def test_service_completed_successfully_maps_to_stopped(self) -> None: + condition = ServiceDependencyCondition.from_value("service_completed_successfully") + self.assertEqual(condition, ServiceDependencyCondition.STOPPED) + + def test_service_healthy_maps_correctly(self) -> None: + condition = ServiceDependencyCondition.from_value("service_healthy") + self.assertEqual(condition, ServiceDependencyCondition.HEALTHY) + + def test_service_started_maps_to_running(self) -> None: + condition = ServiceDependencyCondition.from_value("service_started") + self.assertEqual(condition, ServiceDependencyCondition.RUNNING) + + def test_direct_condition_values(self) -> None: + self.assertEqual( + ServiceDependencyCondition.from_value("stopped"), + ServiceDependencyCondition.STOPPED, + ) + self.assertEqual( + ServiceDependencyCondition.from_value("healthy"), + ServiceDependencyCondition.HEALTHY, + ) + self.assertEqual( + ServiceDependencyCondition.from_value("running"), + ServiceDependencyCondition.RUNNING, + ) + + def test_invalid_condition_raises_error(self) -> None: + with self.assertRaises(ValueError): + ServiceDependencyCondition.from_value("invalid_condition")