Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
125 changes: 124 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,134 @@
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()

# Warn user about potential dependency issues
if not console.confirm(
f"WARNING: Purging {service_name} may introduce issues with the dependency tree.\n"
"Other services that depend on this service may stop working correctly.\n"
"Do you want to continue?"
):
console.info("Purge cancelled.")
return

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
144 changes: 144 additions & 0 deletions tests/commands/test_purge.py
Original file line number Diff line number Diff line change
Expand Up @@ -552,3 +552,147 @@ def test_purge_with_cache_and_state_and_containers_with_networks_and_volumes(
mock.call("network", ["abc", "def", "ghe"]),
]
)


@mock.patch("devservices.commands.purge.Console.confirm")
@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,
mock_console_confirm: mock.Mock,
tmp_path: Path,
) -> None:
"""Test that purging a specific service removes only that service's containers, volumes, and cache."""
mock_console_confirm.return_value = True
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.Console.confirm")
@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,
mock_console_confirm: 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_console_confirm.return_value = True
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


@mock.patch("devservices.commands.purge.Console.confirm")
@mock.patch("devservices.commands.purge.get_matching_containers")
@mock.patch("devservices.commands.purge._get_service_cache_paths")
def test_purge_specific_service_cancelled_by_user(
mock_get_service_cache_paths: mock.Mock,
mock_get_matching_containers: mock.Mock,
mock_console_confirm: mock.Mock,
capsys: pytest.CaptureFixture[str],
tmp_path: Path,
) -> None:
"""Test that purging a service can be cancelled by the user."""
mock_console_confirm.return_value = False
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()
# Add kafka to state
state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES)

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

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

# Should have prompted user
mock_console_confirm.assert_called_once()

# Should not have attempted to get containers
mock_get_matching_containers.assert_not_called()
mock_get_service_cache_paths.assert_not_called()

captured = capsys.readouterr()
assert "Purge cancelled." in captured.out
Loading