Skip to content

Commit 168555a

Browse files
feat(purge): Add ability to purge single service (#296)
* feat(purge): Add ability to purge single service * purge specific service warning prompt --------- Co-authored-by: Hubert Deng <[email protected]>
1 parent 32d4843 commit 168555a

File tree

2 files changed

+268
-1
lines changed

2 files changed

+268
-1
lines changed

devservices/commands/purge.py

Lines changed: 124 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,15 @@
66
from argparse import ArgumentParser
77
from argparse import Namespace
88

9+
from devservices.configs.service_config import load_service_config_from_file
10+
from devservices.constants import DEPENDENCY_CONFIG_VERSION
911
from devservices.constants import DEVSERVICES_CACHE_DIR
12+
from devservices.constants import DEVSERVICES_DEPENDENCIES_CACHE_DIR
1013
from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL
1114
from devservices.constants import DOCKER_NETWORK_NAME
15+
from devservices.exceptions import ConfigNotFoundError
16+
from devservices.exceptions import ConfigParseError
17+
from devservices.exceptions import ConfigValidationError
1218
from devservices.exceptions import DockerDaemonNotRunningError
1319
from devservices.exceptions import DockerError
1420
from devservices.utils.console import Console
@@ -19,17 +25,134 @@
1925
from devservices.utils.docker import remove_docker_resources
2026
from devservices.utils.docker import stop_containers
2127
from devservices.utils.state import State
28+
from devservices.utils.state import StateTables
2229

2330

2431
def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None:
2532
parser = subparsers.add_parser("purge", help="Purge the local devservices cache")
33+
parser.add_argument(
34+
"service_name",
35+
nargs="?",
36+
help="Service name to purge (optional, purges all if not specified)",
37+
default=None,
38+
)
2639
parser.set_defaults(func=purge)
2740

2841

29-
def purge(_args: Namespace) -> None:
42+
def _get_service_cache_paths(service_name: str) -> list[str]:
43+
"""Find cache directory paths for a given service name."""
44+
45+
cache_paths: list[str] = []
46+
dependencies_cache_dir = os.path.join(
47+
DEVSERVICES_DEPENDENCIES_CACHE_DIR, DEPENDENCY_CONFIG_VERSION
48+
)
49+
50+
if not os.path.exists(dependencies_cache_dir):
51+
return cache_paths
52+
53+
for repo_name in os.listdir(dependencies_cache_dir):
54+
repo_path = os.path.join(dependencies_cache_dir, repo_name)
55+
if not os.path.isdir(repo_path):
56+
continue
57+
58+
try:
59+
service_config = load_service_config_from_file(repo_path)
60+
if service_config.service_name == service_name:
61+
cache_paths.append(repo_path)
62+
except (ConfigNotFoundError, ConfigParseError, ConfigValidationError):
63+
# Skip invalid configs
64+
continue
65+
66+
return cache_paths
67+
68+
69+
def purge(args: Namespace) -> None:
3070
"""Purge the local devservices state and cache and remove all devservices containers and volumes."""
3171
console = Console()
72+
service_name = getattr(args, "service_name", None)
73+
74+
if service_name:
75+
_purge_service(service_name, console)
76+
else:
77+
_purge_all(console)
78+
79+
80+
def _purge_service(service_name: str, console: Console) -> None:
81+
"""Purge a specific service."""
82+
state = State()
83+
84+
# Warn user about potential dependency issues
85+
if not console.confirm(
86+
f"WARNING: Purging {service_name} may introduce issues with the dependency tree.\n"
87+
"Other services that depend on this service may stop working correctly.\n"
88+
"Do you want to continue?"
89+
):
90+
console.info("Purge cancelled.")
91+
return
92+
93+
state.remove_service_entry(service_name, StateTables.SERVICE_RUNTIME)
94+
95+
try:
96+
service_containers = get_matching_containers(
97+
[
98+
DEVSERVICES_ORCHESTRATOR_LABEL,
99+
f"com.docker.compose.service={service_name}",
100+
]
101+
)
102+
except DockerDaemonNotRunningError as e:
103+
console.warning(str(e))
104+
service_containers = []
105+
except DockerError as de:
106+
console.failure(f"Failed to get containers for {service_name}: {de.stderr}")
107+
exit(1)
108+
109+
if len(service_containers) == 0:
110+
console.warning(f"No containers found for {service_name}")
111+
else:
112+
try:
113+
service_volumes = get_volumes_for_containers(service_containers)
114+
except DockerError as e:
115+
console.failure(f"Failed to get volumes for {service_name}: {e.stderr}")
116+
exit(1)
117+
118+
with Status(
119+
lambda: console.warning(f"Stopping {service_name} containers"),
120+
lambda: console.success(f"{service_name} containers have been stopped"),
121+
):
122+
try:
123+
stop_containers(service_containers, should_remove=True)
124+
except DockerError as e:
125+
console.failure(f"Failed to stop {service_name} containers: {e.stderr}")
126+
exit(1)
127+
128+
console.warning(f"Removing {service_name} docker volumes")
129+
if len(service_volumes) == 0:
130+
console.success(f"No volumes found for {service_name}")
131+
else:
132+
try:
133+
remove_docker_resources("volume", list(service_volumes))
134+
console.success(f"{service_name} volumes removed")
135+
except DockerError as e:
136+
console.failure(f"Failed to remove {service_name} volumes: {e.stderr}")
137+
138+
cache_paths = _get_service_cache_paths(service_name)
139+
if cache_paths:
140+
console.warning(f"Removing cache for {service_name}")
141+
for cache_path in cache_paths:
142+
try:
143+
shutil.rmtree(cache_path)
144+
except PermissionError as e:
145+
console.failure(f"Failed to remove cache at {cache_path}: {e}")
146+
exit(1)
147+
console.success(f"Cache for {service_name} has been removed")
148+
else:
149+
console.success(f"No cache found for {service_name}")
150+
151+
console.success(f"{service_name} has been purged")
152+
32153

154+
def _purge_all(console: Console) -> None:
155+
"""Purge all devservices state, cache, containers, volumes, and networks."""
33156
if os.path.exists(DEVSERVICES_CACHE_DIR):
34157
try:
35158
shutil.rmtree(DEVSERVICES_CACHE_DIR)

tests/commands/test_purge.py

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -552,3 +552,147 @@ def test_purge_with_cache_and_state_and_containers_with_networks_and_volumes(
552552
mock.call("network", ["abc", "def", "ghe"]),
553553
]
554554
)
555+
556+
557+
@mock.patch("devservices.commands.purge.Console.confirm")
558+
@mock.patch("devservices.commands.purge.get_matching_containers")
559+
@mock.patch("devservices.commands.purge.get_volumes_for_containers")
560+
@mock.patch("devservices.commands.purge.stop_containers")
561+
@mock.patch("devservices.commands.purge.remove_docker_resources")
562+
@mock.patch("devservices.commands.purge._get_service_cache_paths")
563+
def test_purge_specific_service(
564+
mock_get_service_cache_paths: mock.Mock,
565+
mock_remove_docker_resources: mock.Mock,
566+
mock_stop_containers: mock.Mock,
567+
mock_get_volumes_for_containers: mock.Mock,
568+
mock_get_matching_containers: mock.Mock,
569+
mock_console_confirm: mock.Mock,
570+
tmp_path: Path,
571+
) -> None:
572+
"""Test that purging a specific service removes only that service's containers, volumes, and cache."""
573+
mock_console_confirm.return_value = True
574+
mock_get_matching_containers.return_value = [
575+
"kafka-container-1",
576+
"kafka-container-2",
577+
]
578+
mock_get_volumes_for_containers.return_value = ["kafka-volume-1", "kafka-volume-2"]
579+
cache_path = tmp_path / "dependencies" / "v1" / "kafka-repo"
580+
cache_path.mkdir(parents=True, exist_ok=True)
581+
mock_get_service_cache_paths.return_value = [str(cache_path)]
582+
583+
with (
584+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
585+
mock.patch(
586+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
587+
),
588+
):
589+
state = State()
590+
# Don't add kafka to STARTED_SERVICES - it should be stopped before purging
591+
# Add redis to verify it's not affected by kafka purge
592+
state.update_service_entry("redis", "default", StateTables.STARTED_SERVICES)
593+
594+
assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"]
595+
assert cache_path.exists()
596+
597+
purge(Namespace(service_name="kafka"))
598+
599+
# redis should still be in state (unaffected)
600+
assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["redis"]
601+
# Cache path should be removed
602+
assert not cache_path.exists()
603+
604+
# Should filter containers by service name
605+
mock_get_matching_containers.assert_called_once_with(
606+
[
607+
DEVSERVICES_ORCHESTRATOR_LABEL,
608+
"com.docker.compose.service=kafka",
609+
]
610+
)
611+
mock_get_volumes_for_containers.assert_called_once_with(
612+
["kafka-container-1", "kafka-container-2"]
613+
)
614+
mock_stop_containers.assert_called_once_with(
615+
["kafka-container-1", "kafka-container-2"], should_remove=True
616+
)
617+
mock_remove_docker_resources.assert_called_once_with(
618+
"volume", ["kafka-volume-1", "kafka-volume-2"]
619+
)
620+
mock_get_service_cache_paths.assert_called_once_with("kafka")
621+
622+
623+
@mock.patch("devservices.commands.purge.Console.confirm")
624+
@mock.patch("devservices.commands.purge.get_matching_containers")
625+
@mock.patch("devservices.commands.purge._get_service_cache_paths")
626+
def test_purge_specific_service_no_containers(
627+
mock_get_service_cache_paths: mock.Mock,
628+
mock_get_matching_containers: mock.Mock,
629+
mock_console_confirm: mock.Mock,
630+
capsys: pytest.CaptureFixture[str],
631+
tmp_path: Path,
632+
) -> None:
633+
"""Test that purging a service with no containers or cache still removes it from state."""
634+
mock_console_confirm.return_value = True
635+
mock_get_matching_containers.return_value = []
636+
mock_get_service_cache_paths.return_value = []
637+
638+
with (
639+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
640+
mock.patch(
641+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
642+
),
643+
):
644+
state = State()
645+
# Don't add kafka to STARTED_SERVICES - it should be stopped before purging
646+
647+
args = Namespace(service_name="kafka")
648+
purge(args)
649+
650+
# State should remain empty (kafka was never added)
651+
assert state.get_service_entries(StateTables.STARTED_SERVICES) == []
652+
653+
captured = capsys.readouterr()
654+
assert "No containers found for kafka" in captured.out
655+
assert "No cache found for kafka" in captured.out
656+
assert "kafka has been purged" in captured.out
657+
658+
659+
@mock.patch("devservices.commands.purge.Console.confirm")
660+
@mock.patch("devservices.commands.purge.get_matching_containers")
661+
@mock.patch("devservices.commands.purge._get_service_cache_paths")
662+
def test_purge_specific_service_cancelled_by_user(
663+
mock_get_service_cache_paths: mock.Mock,
664+
mock_get_matching_containers: mock.Mock,
665+
mock_console_confirm: mock.Mock,
666+
capsys: pytest.CaptureFixture[str],
667+
tmp_path: Path,
668+
) -> None:
669+
"""Test that purging a service can be cancelled by the user."""
670+
mock_console_confirm.return_value = False
671+
mock_get_matching_containers.return_value = []
672+
mock_get_service_cache_paths.return_value = []
673+
674+
with (
675+
mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")),
676+
mock.patch(
677+
"devservices.utils.docker.check_docker_daemon_running", return_value=None
678+
),
679+
):
680+
state = State()
681+
# Add kafka to state
682+
state.update_service_entry("kafka", "default", StateTables.STARTED_SERVICES)
683+
684+
args = Namespace(service_name="kafka")
685+
purge(args)
686+
687+
# Service should still be in state (purge was cancelled)
688+
assert state.get_service_entries(StateTables.STARTED_SERVICES) == ["kafka"]
689+
690+
# Should have prompted user
691+
mock_console_confirm.assert_called_once()
692+
693+
# Should not have attempted to get containers
694+
mock_get_matching_containers.assert_not_called()
695+
mock_get_service_cache_paths.assert_not_called()
696+
697+
captured = capsys.readouterr()
698+
assert "Purge cancelled." in captured.out

0 commit comments

Comments
 (0)