Skip to content

Commit

Permalink
feat(purge): Adding state clear functionality to purge (#125)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
IanWoodard authored Nov 11, 2024
1 parent 5c7343c commit 06b683a
Show file tree
Hide file tree
Showing 7 changed files with 214 additions and 13 deletions.
25 changes: 24 additions & 1 deletion devservices/commands/purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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")
4 changes: 3 additions & 1 deletion devservices/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions devservices/utils/console.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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."""
Expand Down
23 changes: 20 additions & 3 deletions devservices/utils/docker.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand All @@ -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,
)
9 changes: 9 additions & 0 deletions devservices/utils/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
113 changes: 105 additions & 8 deletions tests/commands/test_purge.py
Original file line number Diff line number Diff line change
@@ -1,35 +1,132 @@
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"
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_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()
47 changes: 47 additions & 0 deletions tests/utils/test_docker.py
Original file line number Diff line number Diff line change
@@ -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,
)

0 comments on commit 06b683a

Please sign in to comment.