diff --git a/newsfragments/add_rm_command.feature b/newsfragments/add_rm_command.feature new file mode 100644 index 00000000..ee6e0ab9 --- /dev/null +++ b/newsfragments/add_rm_command.feature @@ -0,0 +1 @@ +Add `rm` command to remove stopped service containers, matching `docker-compose rm` behavior. diff --git a/podman_compose.py b/podman_compose.py index 307804b9..ab9a14bb 100755 --- a/podman_compose.py +++ b/podman_compose.py @@ -4526,6 +4526,24 @@ async def compose_down(compose: PodmanCompose, args: argparse.Namespace) -> None await compose.podman.run([], "network", ["rm", network]) +@cmd_run(podman_compose, "rm", "remove stopped service containers") +async def compose_rm(compose: PodmanCompose, args: argparse.Namespace) -> None: + services = set(args.services) if args.services else set(compose.services) + containers = list(reversed(compose.containers)) + + for cnt in containers: + if cnt["_service"] not in services: + continue + if args.stop: + await compose.podman.run([], "stop", ["-i", cnt["name"]]) + rm_args = [] + if args.force: + rm_args.append("-f") + if args.volumes: + rm_args.append("-v") + await compose.podman.run([], "rm", [*rm_args, cnt["name"]]) + + @cmd_run(podman_compose, "ps", "show status of containers") async def compose_ps(compose: PodmanCompose, args: argparse.Namespace) -> None: ps_args = ["-a", "--filter", f"label=io.podman.compose.project={compose.project_name}"] @@ -5088,6 +5106,29 @@ def compose_down_parse(parser: argparse.ArgumentParser) -> None: ) +@cmd_parse(podman_compose, "rm") +def compose_rm_parse(parser: argparse.ArgumentParser) -> None: + parser.add_argument( + "-s", + "--stop", + action="store_true", + help="Stop the containers, if required, before removing.", + ) + parser.add_argument( + "-f", + "--force", + action="store_true", + help="Force removal of running or unusable containers. podman-compose has no " + "interactive prompts so behaves differently than docker-compose here.", + ) + parser.add_argument( + "-v", + "--volumes", + action="store_true", + help="Remove any anonymous volumes attached to containers.", + ) + + @cmd_parse(podman_compose, "run") def compose_run_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( @@ -5384,7 +5425,7 @@ def compose_build_up_parse(parser: argparse.ArgumentParser) -> None: ) -@cmd_parse(podman_compose, ["build", "up", "down", "start", "stop", "restart"]) +@cmd_parse(podman_compose, ["build", "up", "down", "start", "stop", "restart", "rm"]) def compose_build_parse(parser: argparse.ArgumentParser) -> None: parser.add_argument( "services", diff --git a/tests/integration/compose_rm_behavior/__init__.py b/tests/integration/compose_rm_behavior/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/tests/integration/compose_rm_behavior/docker-compose_default.yaml b/tests/integration/compose_rm_behavior/docker-compose_default.yaml new file mode 100644 index 00000000..8eb0ff16 --- /dev/null +++ b/tests/integration/compose_rm_behavior/docker-compose_default.yaml @@ -0,0 +1,12 @@ +services: + app: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] + depends_on: + - db + db: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] + no_deps: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] diff --git a/tests/integration/compose_rm_behavior/docker-compose_volumes.yaml b/tests/integration/compose_rm_behavior/docker-compose_volumes.yaml new file mode 100644 index 00000000..45e48c5a --- /dev/null +++ b/tests/integration/compose_rm_behavior/docker-compose_volumes.yaml @@ -0,0 +1,6 @@ +services: + app_with_vol: + image: nopush/podman-compose-test + command: ["dumb-init", "/bin/busybox", "httpd", "-f", "-p", "8080"] + volumes: + - /data diff --git a/tests/integration/compose_rm_behavior/test_compose_rm_behavior.py b/tests/integration/compose_rm_behavior/test_compose_rm_behavior.py new file mode 100644 index 00000000..7bdaf9fc --- /dev/null +++ b/tests/integration/compose_rm_behavior/test_compose_rm_behavior.py @@ -0,0 +1,228 @@ +import os +import unittest + +from parameterized import parameterized + +from tests.integration.test_utils import RunSubprocessMixin +from tests.integration.test_utils import podman_compose_path +from tests.integration.test_utils import test_path + + +def compose_yaml_path(scenario: str) -> str: + return os.path.join( + os.path.join(test_path(), "compose_rm_behavior"), f"docker-compose_{scenario}.yaml" + ) + + +class TestComposeRmBehavior(unittest.TestCase, RunSubprocessMixin): + @parameterized.expand([ + ("default", ["rm"], set()), + ( + "default", + ["rm", "app"], + { + "compose_rm_behavior_db_1", + "compose_rm_behavior_no_deps_1", + }, + ), + ( + "default", + ["rm", "no_deps"], + { + "compose_rm_behavior_app_1", + "compose_rm_behavior_db_1", + }, + ), + ]) + def test_compose_rm( + self, scenario: str, command_args: list[str], expect_remaining: set[str] + ) -> None: + try: + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], + ) + + # stop containers before rm (rm only removes stopped containers by default) + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "stop"], + ) + + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + *command_args, + ], + ) + + out, _ = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "ps", + "--format", + '{{ .Names }}', + ], + ) + + actual_containers = set() + for line in out.decode('utf-8').strip().split('\n'): + name = line.strip() + if name: + actual_containers.add(name) + + self.assertEqual(actual_containers, expect_remaining) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "down", + "-t", + "0", + ]) + + def test_compose_rm_stop_flag(self) -> None: + scenario = "default" + try: + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], + ) + + # rm --stop should stop and remove running containers + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "rm", + "-s", + ], + ) + + out, _ = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "ps", + "--format", + '{{ .Names }}', + ], + ) + + actual_containers = set() + for line in out.decode('utf-8').strip().split('\n'): + name = line.strip() + if name: + actual_containers.add(name) + + self.assertEqual(actual_containers, set()) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "down", + "-t", + "0", + ]) + + def test_compose_rm_force_flag(self) -> None: + scenario = "default" + try: + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], + ) + + # rm -f should force remove running containers + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "rm", + "-f", + ], + ) + + out, _ = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "ps", + "--format", + '{{ .Names }}', + ], + ) + + actual_containers = set() + for line in out.decode('utf-8').strip().split('\n'): + name = line.strip() + if name: + actual_containers.add(name) + + self.assertEqual(actual_containers, set()) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "down", + "-t", + "0", + ]) + + def test_compose_rm_volumes_flag(self) -> None: + scenario = "volumes" + try: + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "up", "-d"], + ) + + # stop containers before rm (rm only removes stopped containers by default) + self.run_subprocess_assert_returncode( + [podman_compose_path(), "-f", compose_yaml_path(scenario), "stop"], + ) + + # rm -v should remove anonymous volumes attached to containers + self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "rm", + "-v", + ], + ) + + out, _ = self.run_subprocess_assert_returncode( + [ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "ps", + "--format", + '{{ .Names }}', + ], + ) + + actual_containers = set() + for line in out.decode('utf-8').strip().split('\n'): + name = line.strip() + if name: + actual_containers.add(name) + + self.assertEqual(actual_containers, set()) + finally: + self.run_subprocess_assert_returncode([ + podman_compose_path(), + "-f", + compose_yaml_path(scenario), + "down", + "-t", + "0", + ])