Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 126 additions & 1 deletion devservices/commands/purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,15 @@
from argparse import ArgumentParser
from argparse import Namespace

from devservices.configs.service_config import load_service_config_from_file
from devservices.constants import DEPENDENCY_CONFIG_VERSION
from devservices.constants import DEVSERVICES_CACHE_DIR
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL
from devservices.constants import DOCKER_NETWORK_NAME
from devservices.exceptions import ConfigNotFoundError
from devservices.exceptions import ConfigParseError
from devservices.exceptions import ConfigValidationError
from devservices.exceptions import DockerDaemonNotRunningError
from devservices.exceptions import DockerError
from devservices.utils.console import Console
Expand All @@ -19,17 +25,136 @@
from devservices.utils.docker import remove_docker_resources
from devservices.utils.docker import stop_containers
from devservices.utils.state import State
from devservices.utils.state import StateTables


def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
parser = subparsers.add_parser("purge", help="Purge the local devservices cache")
parser.add_argument(
"service_name",
nargs="?",
help="Service name to purge (optional, purges all if not specified)",
default=None,
)
parser.set_defaults(func=purge)


def purge(_args: Namespace) -> None:
def _get_service_cache_paths(service_name: str) -> list[str]:
"""Find cache directory paths for a given service name."""

cache_paths: list[str] = []
dependencies_cache_dir = os.path.join(
DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION
)

if not os.path.exists(dependencies_cache_dir):
return cache_paths

for repo_name in os.listdir(dependencies_cache_dir):
repo_path = os.path.join(dependencies_cache_dir, repo_name)
if not os.path.isdir(repo_path):
continue

try:
service_config = load_service_config_from_file(repo_path)
if service_config.service_name == service_name:
cache_paths.append(repo_path)
except (ConfigNotFoundError, ConfigParseError, ConfigValidationError):
# Skip invalid configs
continue

return cache_paths


def purge(args: Namespace) -> None:
"""Purge the local devservices state and cache and remove all devservices containers and volumes."""
console = Console()
service_name = getattr(args, "service_name", None)

if service_name:
_purge_service(service_name, console)
else:
_purge_all(console)


def _purge_service(service_name: str, console: Console) -> None:
"""Purge a specific service."""
state = State()

# Check if service is currently running or starting
started_services = state.get_service_entries(StateTables.STARTED_SERVICES)
starting_services = state.get_service_entries(StateTables.STARTING_SERVICES)

if service_name in started_services or service_name in starting_services:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just attempted this in my local environment. Actually my bad, looks like dependent services are not added to StateTables.STARTED_SERVICES and StateTables.STARTING_SERVICES so this won't work.

I'm ok adding this as long as we add a warning message and prompt user before proceeding when purging specific services

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added it to the PR, fyi in order to test this locally you can just do ~/code/devservices/.venv/bin/devservices purge redis to invoke the devservices from the venv of this repository

console.failure(
f"Cannot purge {service_name} while it is running or starting. "
f"Please stop the service first using 'devservices down {service_name}'"
)
exit(1)

state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME)

try:
service_containers = get_matching_containers(
[
DEVSERVICES_ORCHESTRATOR_LABEL,
f"com.docker.compose.service={service_name}",
]
)
except DockerDaemonNotRunningError as e:
console.warning(str(e))
service_containers = []
except DockerError as de:
console.failure(f"Failed to get containers for {service_name}: {de.stderr}")
exit(1)

if len(service_containers) == 0:
console.warning(f"No containers found for {service_name}")
else:
try:
service_volumes = get_volumes_for_containers(service_containers)
except DockerError as e:
console.failure(f"Failed to get volumes for {service_name}: {e.stderr}")
exit(1)

with Status(
lambda: console.warning(f"Stopping {service_name} containers"),
lambda: console.success(f"{service_name} containers have been stopped"),
):
try:
stop_containers(service_containers, should_remove=True)
except DockerError as e:
console.failure(f"Failed to stop {service_name} containers: {e.stderr}")
exit(1)

console.warning(f"Removing {service_name} docker volumes")
if len(service_volumes) == 0:
console.success(f"No volumes found for {service_name}")
else:
try:
remove_docker_resources("volume", list(service_volumes))
console.success(f"{service_name} volumes removed")
except DockerError as e:
console.failure(f"Failed to remove {service_name} volumes: {e.stderr}")

cache_paths = _get_service_cache_paths(service_name)
if cache_paths:
console.warning(f"Removing cache for {service_name}")
for cache_path in cache_paths:
try:
shutil.rmtree(cache_path)
except PermissionError as e:
console.failure(f"Failed to remove cache at {cache_path}: {e}")
exit(1)
console.success(f"Cache for {service_name} has been removed")
else:
console.success(f"No cache found for {service_name}")

console.success(f"{service_name} has been purged")


def _purge_all(console: Console) -> None:
"""Purge all devservices state, cache, containers, volumes, and networks."""
if os.path.exists(DEVSERVICES_CACHE_DIR):
try:
shutil.rmtree(DEVSERVICES_CACHE_DIR)
Expand Down
142 changes: 142 additions & 0 deletions tests/commands/test_purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,145 @@ def test_purge_with_cache_and_state_and_containers_with_networks_and_volumes(
mock.call("network", ["abc", "def", "ghe"]),
]
)


@mock.patch("devservices.commands.purge.get_matching_containers")
@mock.patch("devservices.commands.purge.get_volumes_for_containers")
@mock.patch("devservices.commands.purge.stop_containers")
@mock.patch("devservices.commands.purge.remove_docker_resources")
@mock.patch("devservices.commands.purge._get_service_cache_paths")
def test_purge_specific_service(
mock_get_service_cache_paths: mock.Mock,
mock_remove_docker_resources: mock.Mock,
mock_stop_containers: mock.Mock,
mock_get_volumes_for_containers: mock.Mock,
mock_get_matching_containers: mock.Mock,
tmp_path: Path,
) -> None:
"""Test that purging a specific service removes only that service's containers, volumes, and cache."""
mock_get_matching_containers.return_value = [
"kafka-container-1",
"kafka-container-2",
]
mock_get_volumes_for_containers.return_value = ["kafka-volume-1", "kafka-volume-2"]
cache_path = tmp_path / "dependencies" / "v1" / "kafka-repo"
cache_path.mkdir(parents=True, exist_ok=True)
mock_get_service_cache_paths.return_value = [str(cache_path)]

with (
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch(
"devservices.utils.docker.check_docker_daemon_running", return_value=None
),
):
state = State()
# Don't add kafka to STARTED_SERVICES - it should be stopped before purging
# Add redis to verify it's not affected by kafka purge
state.update_service_entry("redis", "default", StateTables.STARTED_SERVICES)

assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"]
assert cache_path.exists()

purge(Namespace(service_name="kafka"))

# redis should still be in state (unaffected)
assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"]
# Cache path should be removed
assert not cache_path.exists()

# Should filter containers by service name
mock_get_matching_containers.assert_called_once_with(
[
DEVSERVICES_ORCHESTRATOR_LABEL,
"com.docker.compose.service=kafka",
]
)
mock_get_volumes_for_containers.assert_called_once_with(
["kafka-container-1", "kafka-container-2"]
)
mock_stop_containers.assert_called_once_with(
["kafka-container-1", "kafka-container-2"], should_remove=True
)
mock_remove_docker_resources.assert_called_once_with(
"volume", ["kafka-volume-1", "kafka-volume-2"]
)
mock_get_service_cache_paths.assert_called_once_with("kafka")


@mock.patch("devservices.commands.purge.get_matching_containers")
@mock.patch("devservices.commands.purge._get_service_cache_paths")
def test_purge_specific_service_no_containers(
mock_get_service_cache_paths: mock.Mock,
mock_get_matching_containers: mock.Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""Test that purging a service with no containers or cache still removes it from state."""
mock_get_matching_containers.return_value = []
mock_get_service_cache_paths.return_value = []

with (
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
mock.patch(
"devservices.utils.docker.check_docker_daemon_running", return_value=None
),
):
state = State()
# Don't add kafka to STARTED_SERVICES - it should be stopped before purging

args = Namespace(service_name="kafka")
purge(args)

# State should remain empty (kafka was never added)
assert state.get_service_entries(StateTables.STARTED_SERVICES) == []

captured = capsys.readouterr()
assert "No containers found for kafka" in captured.out
assert "No cache found for kafka" in captured.out
assert "kafka has been purged" in captured.out


def test_purge_specific_service_fails_if_started(
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""Test that purging a service that is in STARTED_SERVICES fails with an error."""
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES)

args = Namespace(service_name="kafka")
with pytest.raises(SystemExit) as exc_info:
purge(args)

assert exc_info.value.code == 1

# Service should still be in state (purge was blocked)
assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["kafka"]

captured = capsys.readouterr()
assert "Cannot purge kafka while it is running or starting" in captured.out
assert "Please stop the service first" in captured.out


def test_purge_specific_service_fails_if_starting(
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""Test that purging a service that is in STARTING_SERVICES fails with an error."""
with mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")):
state = State()
state.update_service_entry("kafka", "default", StateTables.STARTING_SERVICES)

args = Namespace(service_name="kafka")
with pytest.raises(SystemExit) as exc_info:
purge(args)

assert exc_info.value.code == 1

# Service should still be in state (purge was blocked)
assert state.get_service_entries(StateTables.STARTING_SERVICES) == ["kafka"]

captured = capsys.readouterr()
assert "Cannot purge kafka while it is running or starting" in captured.out
assert "Please stop the service first" in captured.out
Loading