From 06b683aabea12e524aa1f486025e6333b43a047f Mon Sep 17 00:00:00 2001 From: Ian Woodard <17186604+IanWoodard@users.noreply.github.com> Date: Mon, 11 Nov 2024 14:27:40 -0800 Subject: [PATCH] feat(purge): Adding state clear functionality to purge (#125) * feat(purge): Adding state clear functionality to purge * Adding docker daemon check for purge * Adding docker tests * Adding a test with no running containers * Addressing review feedback * Addressing review feedback --- devservices/commands/purge.py | 25 +++++++- devservices/exceptions.py | 4 +- devservices/utils/console.py | 6 ++ devservices/utils/docker.py | 23 ++++++- devservices/utils/state.py | 9 +++ tests/commands/test_purge.py | 113 +++++++++++++++++++++++++++++++--- tests/utils/test_docker.py | 47 ++++++++++++++ 7 files changed, 214 insertions(+), 13 deletions(-) create mode 100644 tests/utils/test_docker.py diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index 8a5053d..2a51da9 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -7,7 +7,11 @@ from argparse import Namespace from devservices.constants import DEVSERVICES_CACHE_DIR +from devservices.exceptions import DockerDaemonNotRunningError from devservices.utils.console import Console +from devservices.utils.console import Status +from devservices.utils.docker import stop_all_running_containers +from devservices.utils.state import State def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: @@ -18,10 +22,29 @@ def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: def purge(args: Namespace) -> None: """Purge the local devservices cache.""" console = Console() + # Prompt the user to stop all running containers + should_stop_containers = console.confirm( + "Warning: Purging stops all running containers and clears devservices state. Would you like to continue?" + ) + if not should_stop_containers: + console.warning("Purge canceled") + return + if os.path.exists(DEVSERVICES_CACHE_DIR): try: shutil.rmtree(DEVSERVICES_CACHE_DIR) except PermissionError as e: console.failure(f"Failed to purge cache: {e}") exit(1) - console.success("The local devservices cache has been purged") + state = State() + state.clear_state() + with Status( + lambda: console.warning("Stopping all running containers"), + lambda: console.success("All running containers have been stopped"), + ): + try: + stop_all_running_containers() + except DockerDaemonNotRunningError: + console.warning("The docker daemon not running, no containers to stop") + + console.success("The local devservices cache and state has been purged") diff --git a/devservices/exceptions.py b/devservices/exceptions.py index b67aa50..f5a372f 100644 --- a/devservices/exceptions.py +++ b/devservices/exceptions.py @@ -46,7 +46,9 @@ class DevservicesUpdateError(BinaryInstallError): class DockerDaemonNotRunningError(Exception): """Raised when the Docker daemon is not running.""" - pass + def __str__(self) -> str: + # TODO: Provide explicit instructions on what to do + return "Unable to connect to the docker daemon. Is the docker daemon running?" class DockerComposeInstallationError(BinaryInstallError): diff --git a/devservices/utils/console.py b/devservices/utils/console.py index ba6ed87..226afeb 100644 --- a/devservices/utils/console.py +++ b/devservices/utils/console.py @@ -14,6 +14,7 @@ class Color: RED = "\033[0;31m" GREEN = "\033[0;32m" YELLOW = "\033[0;33m" + BLUE = "\033[0;34m" BOLD = "\033[1m" UNDERLINE = "\033[4m" NEGATIVE = "\033[7m" @@ -46,6 +47,11 @@ def warning(self, message: str, bold: bool = False) -> None: def info(self, message: str, bold: bool = False) -> None: self.print(message=message, color="", bold=bold) + def confirm(self, message: str) -> bool: + self.warning(message=message, bold=True) + response = input("(Y/n): ").strip().lower() + return response in ("y", "yes", "") + class Status: """Shows loading status in the terminal.""" diff --git a/devservices/utils/docker.py b/devservices/utils/docker.py index 6f96e9d..a1dd765 100644 --- a/devservices/utils/docker.py +++ b/devservices/utils/docker.py @@ -6,6 +6,7 @@ def check_docker_daemon_running() -> None: + """Checks if the Docker daemon is running. Raises DockerDaemonNotRunningError if not.""" try: subprocess.run( ["docker", "info"], @@ -14,6 +15,22 @@ def check_docker_daemon_running() -> None: check=True, ) except subprocess.CalledProcessError as e: - raise DockerDaemonNotRunningError( - "Unable to connect to the docker daemon. Is the docker daemon running?" - ) from e + raise DockerDaemonNotRunningError from e + + +def stop_all_running_containers() -> None: + check_docker_daemon_running() + running_containers = ( + subprocess.check_output(["docker", "ps", "-q"], stderr=subprocess.DEVNULL) + .decode() + .strip() + .splitlines() + ) + if len(running_containers) == 0: + return + subprocess.run( + ["docker", "stop"] + running_containers, + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) diff --git a/devservices/utils/state.py b/devservices/utils/state.py index cb41f0c..16670a1 100644 --- a/devservices/utils/state.py +++ b/devservices/utils/state.py @@ -79,3 +79,12 @@ def get_mode_for_service(self, service_name: str) -> str | None: if result is None: return None return str(result[0]) + + def clear_state(self) -> None: + cursor = self.conn.cursor() + cursor.execute( + """ + DELETE FROM started_services + """ + ) + self.conn.commit() diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index 04ed5c3..99f4313 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -1,25 +1,46 @@ from __future__ import annotations +import builtins from argparse import Namespace from pathlib import Path from unittest import mock from devservices.commands.purge import purge +from devservices.utils.state import State -def test_purge_no_cache(tmp_path: Path) -> None: - with mock.patch( - "devservices.commands.purge.DEVSERVICES_CACHE_DIR", - str(tmp_path / ".devservices-cache"), +@mock.patch("devservices.commands.purge.stop_all_running_containers") +def test_purge_not_confirmed( + mock_stop_all_running_containers: mock.Mock, tmp_path: Path +) -> None: + with ( + mock.patch( + "devservices.commands.purge.DEVSERVICES_CACHE_DIR", + str(tmp_path / ".devservices-cache"), + ), + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch.object(builtins, "input", lambda _: "no"), ): args = Namespace() purge(args) + mock_stop_all_running_containers.assert_not_called() -def test_purge_with_cache(tmp_path: Path) -> None: - with mock.patch( - "devservices.commands.purge.DEVSERVICES_CACHE_DIR", - str(tmp_path / ".devservices-cache"), + +@mock.patch("devservices.commands.purge.stop_all_running_containers") +def test_purge_with_cache_and_state_and_no_running_containers_confirmed( + mock_stop_all_running_containers: mock.Mock, tmp_path: Path +) -> None: + with ( + mock.patch( + "devservices.commands.purge.DEVSERVICES_CACHE_DIR", + str(tmp_path / ".devservices-cache"), + ), + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch.object(builtins, "input", lambda _: "yes"), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), ): # Create a cache file to test purging cache_dir = tmp_path / ".devservices-cache" @@ -27,9 +48,85 @@ def test_purge_with_cache(tmp_path: Path) -> None: cache_file = tmp_path / ".devservices-cache" / "test.txt" cache_file.write_text("This is a test cache file.") + state = State() + state.add_started_service("test-service", "test-mode") + assert cache_file.exists() + assert state.get_started_services() == ["test-service"] args = Namespace() purge(args) assert not cache_file.exists() + assert state.get_started_services() == [] + + mock_stop_all_running_containers.assert_called_once() + + +@mock.patch("devservices.commands.purge.stop_all_running_containers") +def test_purge_with_cache_and_state_and_running_containers_confirmed( + mock_stop_all_running_containers: mock.Mock, tmp_path: Path +) -> None: + with ( + mock.patch( + "devservices.commands.purge.DEVSERVICES_CACHE_DIR", + str(tmp_path / ".devservices-cache"), + ), + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch.object(builtins, "input", lambda _: "yes"), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), + ): + # Create a cache file to test purging + cache_dir = tmp_path / ".devservices-cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = tmp_path / ".devservices-cache" / "test.txt" + cache_file.write_text("This is a test cache file.") + + state = State() + state.add_started_service("test-service", "test-mode") + + assert cache_file.exists() + assert state.get_started_services() == ["test-service"] + + args = Namespace() + purge(args) + + assert not cache_file.exists() + assert state.get_started_services() == [] + + mock_stop_all_running_containers.assert_called_once() + + +@mock.patch("devservices.commands.purge.stop_all_running_containers") +def test_purge_with_cache_and_state_and_running_containers_not_confirmed( + mock_stop_all_running_containers: mock.Mock, tmp_path: Path +) -> None: + with ( + mock.patch( + "devservices.commands.purge.DEVSERVICES_CACHE_DIR", + str(tmp_path / ".devservices-cache"), + ), + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch.object(builtins, "input", lambda _: "no"), + mock.patch( + "devservices.utils.docker.check_docker_daemon_running", return_value=None + ), + ): + # Create a cache file to test purging + cache_dir = tmp_path / ".devservices-cache" + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = tmp_path / ".devservices-cache" / "test.txt" + cache_file.write_text("This is a test cache file.") + + state = State() + state.add_started_service("test-service", "test-mode") + + args = Namespace() + purge(args) + + assert cache_file.exists() + assert state.get_started_services() == ["test-service"] + + mock_stop_all_running_containers.assert_not_called() diff --git a/tests/utils/test_docker.py b/tests/utils/test_docker.py new file mode 100644 index 0000000..1553de9 --- /dev/null +++ b/tests/utils/test_docker.py @@ -0,0 +1,47 @@ +from __future__ import annotations + +import subprocess +from unittest import mock + +from devservices.utils.docker import stop_all_running_containers + + +@mock.patch("subprocess.check_output") +@mock.patch("subprocess.run") +@mock.patch("devservices.utils.docker.check_docker_daemon_running") +def test_stop_all_running_containers_none_running( + mock_check_docker_daemon_running: mock.Mock, + mock_run: mock.Mock, + mock_check_output: mock.Mock, +) -> None: + mock_check_docker_daemon_running.return_value = None + mock_check_output.return_value = b"" + stop_all_running_containers() + mock_check_docker_daemon_running.assert_called_once() + mock_check_output.assert_called_once_with( + ["docker", "ps", "-q"], stderr=subprocess.DEVNULL + ) + mock_run.assert_not_called() + + +@mock.patch("subprocess.check_output") +@mock.patch("subprocess.run") +@mock.patch("devservices.utils.docker.check_docker_daemon_running") +def test_stop_all_running_containers( + mock_check_docker_daemon_running: mock.Mock, + mock_run: mock.Mock, + mock_check_output: mock.Mock, +) -> None: + mock_check_docker_daemon_running.return_value = None + mock_check_output.return_value = b"container1\ncontainer2\n" + stop_all_running_containers() + mock_check_docker_daemon_running.assert_called_once() + mock_check_output.assert_called_once_with( + ["docker", "ps", "-q"], stderr=subprocess.DEVNULL + ) + mock_run.assert_called_once_with( + ["docker", "stop", "container1", "container2"], + check=True, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + )