diff --git a/devservices/commands/purge.py b/devservices/commands/purge.py index a63f7e8e..733e1598 100644 --- a/devservices/commands/purge.py +++ b/devservices/commands/purge.py @@ -40,7 +40,9 @@ def purge(_args: Namespace) -> None: state.clear_state() try: - devservices_containers = get_matching_containers(DEVSERVICES_ORCHESTRATOR_LABEL) + devservices_containers = get_matching_containers( + [DEVSERVICES_ORCHESTRATOR_LABEL] + ) except DockerDaemonNotRunningError as e: console.warning(str(e)) return diff --git a/devservices/commands/reset.py b/devservices/commands/reset.py new file mode 100644 index 00000000..1e936c5f --- /dev/null +++ b/devservices/commands/reset.py @@ -0,0 +1,115 @@ +from __future__ import annotations + +from argparse import _SubParsersAction +from argparse import ArgumentParser +from argparse import Namespace + +from sentry_sdk import capture_exception + +from devservices.commands.down import down +from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL +from devservices.exceptions import DockerDaemonNotRunningError +from devservices.exceptions import DockerError +from devservices.utils.console import Console +from devservices.utils.console import Status +from devservices.utils.dependencies import construct_dependency_graph +from devservices.utils.dependencies import DependencyNode +from devservices.utils.dependencies import DependencyType +from devservices.utils.docker import get_matching_containers +from devservices.utils.docker import get_volumes_for_containers +from devservices.utils.docker import remove_docker_resources +from devservices.utils.docker import stop_containers +from devservices.utils.services import find_matching_service +from devservices.utils.state import State +from devservices.utils.state import StateTables + + +def add_parser(subparsers: _SubParsersAction[ArgumentParser]) -> None: + parser = subparsers.add_parser("reset", help="Reset a service's volumes") + parser.add_argument( + "service_name", + help="Name of the service to reset volumes for", + nargs="?", + default=None, + ) + parser.set_defaults(func=reset) + + +def reset(args: Namespace) -> None: + """Reset a specified service's volumes.""" + console = Console() + service_name = args.service_name + + try: + matching_containers = get_matching_containers( + [ + DEVSERVICES_ORCHESTRATOR_LABEL, + f"com.docker.compose.service={args.service_name}", + ] + ) + except DockerDaemonNotRunningError as e: + console.warning(str(e)) + return + except DockerError as e: + console.failure(f"Failed to get matching containers {e.stderr}") + exit(1) + + if len(matching_containers) == 0: + console.failure(f"No containers found for {service_name}") + exit(1) + + try: + matching_volumes = get_volumes_for_containers(matching_containers) + except DockerError as e: + console.failure(f"Failed to get matching volumes {e.stderr}") + exit(1) + + if len(matching_volumes) == 0: + console.failure(f"No volumes found for {service_name}") + exit(1) + + state = State() + started_services = set(state.get_service_entries(StateTables.STARTED_SERVICES)) + starting_services = set(state.get_service_entries(StateTables.STARTING_SERVICES)) + active_service_names = starting_services.union(started_services) + + # TODO: We should add threading here to speed up the process + for active_service_name in active_service_names: + active_service = find_matching_service(active_service_name) + starting_active_modes = state.get_active_modes_for_service( + active_service_name, StateTables.STARTING_SERVICES + ) + started_active_modes = state.get_active_modes_for_service( + active_service_name, StateTables.STARTED_SERVICES + ) + active_modes = starting_active_modes or started_active_modes + dependency_graph = construct_dependency_graph(active_service, active_modes) + if ( + DependencyNode(name=service_name, dependency_type=DependencyType.COMPOSE) + in dependency_graph.graph + ): + console.warning( + f"Bringing down {active_service_name} in order to safely reset {service_name}" + ) + down(Namespace(service_name=active_service_name, exclude_local=True)) + + with Status( + lambda: console.warning(f"Resetting docker volumes for {service_name}"), + lambda: console.success(f"Docker volumes have been reset for {service_name}"), + ): + try: + stop_containers(matching_containers, should_remove=True) + except DockerError as e: + console.failure( + f"Failed to stop and remove {', '.join(matching_containers)}\nError: {e.stderr}" + ) + capture_exception(e) + exit(1) + try: + remove_docker_resources("volume", list(matching_volumes)) + except DockerError as e: + console.failure( + f"Failed to remove volumes {', '.join(matching_volumes)}\nError: {e.stderr}" + ) + capture_exception(e) + exit(1) diff --git a/devservices/main.py b/devservices/main.py index 2e1c2473..fd2c3aec 100644 --- a/devservices/main.py +++ b/devservices/main.py @@ -24,6 +24,7 @@ from devservices.commands import list_services from devservices.commands import logs from devservices.commands import purge +from devservices.commands import reset from devservices.commands import serve from devservices.commands import status from devservices.commands import toggle @@ -150,6 +151,7 @@ def main() -> None: purge.add_parser(subparsers) serve.add_parser(subparsers) toggle.add_parser(subparsers) + reset.add_parser(subparsers) args = parser.parse_args() diff --git a/devservices/utils/docker.py b/devservices/utils/docker.py index c23cd124..5b3bae04 100644 --- a/devservices/utils/docker.py +++ b/devservices/utils/docker.py @@ -88,11 +88,14 @@ def wait_for_healthy(container: ContainerNames, status: Status) -> None: raise ContainerHealthcheckFailedError(container.short_name, HEALTHCHECK_TIMEOUT) -def get_matching_containers(label: str) -> list[str]: +def get_matching_containers(labels: list[str]) -> list[str]: """ Returns a list of container names with the given label """ check_docker_daemon_running() + filters = [] + for label in labels: + filters.extend(["--filter", f"label={label}"]) try: return ( subprocess.check_output( @@ -101,9 +104,8 @@ def get_matching_containers(label: str) -> list[str]: "ps", "-a", "-q", - "--filter", - f"label={label}", - ], + ] + + filters, text=True, stderr=subprocess.DEVNULL, ) @@ -112,7 +114,7 @@ def get_matching_containers(label: str) -> list[str]: ) except subprocess.CalledProcessError as e: raise DockerError( - command=f"docker ps -q --filter label={label}", + command=f"docker ps -a -q {' '.join(filters)}", returncode=e.returncode, stdout=e.stdout, stderr=e.stderr, @@ -196,7 +198,7 @@ def stop_containers(containers: list[str], should_remove: bool = False) -> None: ["docker", "stop"] + containers, check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ) except subprocess.CalledProcessError as e: raise DockerError( @@ -218,7 +220,7 @@ def remove_docker_resources(resource_type: str, resources: list[str]) -> None: ["docker", resource_type, "rm", *resources], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ) except subprocess.CalledProcessError as e: raise DockerError( diff --git a/tests/commands/test_purge.py b/tests/commands/test_purge.py index f967a632..e2f4d5c0 100644 --- a/tests/commands/test_purge.py +++ b/tests/commands/test_purge.py @@ -58,7 +58,7 @@ def test_purge_docker_daemon_not_running( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_not_called() mock_stop_containers.assert_not_called() @@ -117,7 +117,7 @@ def test_purge_docker_error_get_matching_containers( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_not_called() mock_stop_containers.assert_not_called() @@ -174,7 +174,7 @@ def test_purge_docker_error_get_volumes_for_containers( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_called_once_with(["abc", "def", "ghi"]) mock_stop_containers.assert_not_called() @@ -234,7 +234,7 @@ def test_purge_docker_error_get_matching_networks( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_called_once_with(["abc", "def", "ghi"]) mock_stop_containers.assert_called_once_with( @@ -293,7 +293,7 @@ def test_purge_docker_error_stop_containers( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_called_once_with(["abc", "def", "ghi"]) mock_stop_containers.assert_called_once_with( @@ -355,7 +355,7 @@ def test_purge_docker_error_remove_volumes_continues_to_remove_networks( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_called_once_with(["abc", "def", "ghi"]) mock_stop_containers.assert_called_once_with( @@ -425,7 +425,7 @@ def test_purge_docker_error_remove_networks( assert state.get_service_entries(StateTables.STARTED_SERVICES) == [] mock_get_matching_containers.assert_called_once_with( - DEVSERVICES_ORCHESTRATOR_LABEL + [DEVSERVICES_ORCHESTRATOR_LABEL] ) mock_get_volumes_for_containers.assert_called_once_with(["abc", "def", "ghi"]) mock_stop_containers.assert_called_once_with( diff --git a/tests/commands/test_reset.py b/tests/commands/test_reset.py new file mode 100644 index 00000000..e92c2664 --- /dev/null +++ b/tests/commands/test_reset.py @@ -0,0 +1,531 @@ +from __future__ import annotations + +from argparse import Namespace +from pathlib import Path +from unittest import mock + +import pytest + +from devservices.commands.reset import reset +from devservices.constants import DEVSERVICES_ORCHESTRATOR_LABEL +from devservices.exceptions import DockerDaemonNotRunningError +from devservices.exceptions import DockerError +from devservices.utils.state import State +from devservices.utils.state import StateTables +from testing.utils import create_config_file + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", + side_effect=DockerDaemonNotRunningError(), +) +def test_reset_docker_daemon_not_running( + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="test-service") + + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=test-service"] + ) + + captured = capsys.readouterr() + assert ( + "Unable to connect to the docker daemon. Is the docker daemon running?" + in captured.out.strip() + ) + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", + side_effect=DockerError( + command="test-command", returncode=1, stdout="", stderr="test error" + ), +) +def test_reset_failed_to_get_matching_containers( + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace() + args.service_name = "test-service" + + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=test-service"] + ) + + captured = capsys.readouterr() + assert "Failed to get matching containers" in captured.out + + +@mock.patch("devservices.commands.reset.get_matching_containers", return_value=[]) +def test_reset_no_matching_containers( + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace() + args.service_name = "test-service" + + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=test-service"] + ) + + captured = capsys.readouterr() + assert "No containers found for test-service" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["test-service"] +) +@mock.patch("devservices.commands.reset.get_volumes_for_containers", return_value=[]) +def test_reset_no_matching_volumes( + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="test-service") + + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=test-service"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["test-service"]) + + captured = capsys.readouterr() + assert "No volumes found for test-service" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["test-service"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + side_effect=DockerError( + command="test-command", returncode=1, stdout="", stderr="test error" + ), +) +def test_reset_failed_to_get_matching_volumes( + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace() + args.service_name = "test-service" + + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=test-service"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["test-service"]) + + captured = capsys.readouterr() + assert "Failed to get matching volumes" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["redis"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + return_value=["redis-volume"], +) +@mock.patch("devservices.commands.reset.down") +@mock.patch( + "devservices.commands.reset.stop_containers", + side_effect=DockerError( + command="test-command", returncode=1, stdout="", stderr="test error" + ), +) +@mock.patch("devservices.commands.reset.remove_docker_resources") +def test_reset_with_service_name_container_removal_error( + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_down: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="redis") + service_path = tmp_path / "code" / "test-service" + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "redis": {"description": "Redis"}, + "clickhouse": {"description": "Clickhouse"}, + }, + "modes": {"default": ["redis", "clickhouse"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "clickhouse": { + "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" + }, + }, + } + create_config_file(service_path, config) + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path / "code"), + ), + ): + state = State() + state.update_service_entry( + "test-service", "default", StateTables.STARTED_SERVICES + ) + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=redis"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["redis"]) + mock_down.assert_called_once_with( + Namespace(service_name="test-service", exclude_local=True) + ) + mock_stop_containers.assert_called_once_with(["redis"], should_remove=True) + mock_remove_docker_resources.assert_not_called() + + captured = capsys.readouterr() + assert "Resetting docker volumes for redis" in captured.out + assert "Bringing down test-service in order to safely reset redis" in captured.out + assert "Failed to stop and remove redis\nError: test error" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["redis"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + return_value=["redis-volume"], +) +@mock.patch("devservices.commands.reset.down") +@mock.patch("devservices.commands.reset.stop_containers") +@mock.patch( + "devservices.commands.reset.remove_docker_resources", + side_effect=DockerError( + command="test-command", returncode=1, stdout="", stderr="test error" + ), +) +def test_reset_with_service_name_volume_removal_error( + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_down: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="redis") + service_path = tmp_path / "code" / "test-service" + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "redis": {"description": "Redis"}, + "clickhouse": {"description": "Clickhouse"}, + }, + "modes": {"default": ["redis", "clickhouse"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "clickhouse": { + "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" + }, + }, + } + create_config_file(service_path, config) + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path / "code"), + ), + ): + state = State() + state.update_service_entry( + "test-service", "default", StateTables.STARTED_SERVICES + ) + with pytest.raises(SystemExit): + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=redis"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["redis"]) + mock_down.assert_called_once_with( + Namespace(service_name="test-service", exclude_local=True) + ) + mock_stop_containers.assert_called_once_with(["redis"], should_remove=True) + mock_remove_docker_resources.assert_called_once_with("volume", ["redis-volume"]) + + captured = capsys.readouterr() + assert "Resetting docker volumes for redis" in captured.out + assert "Bringing down test-service in order to safely reset redis" in captured.out + assert "Failed to remove volumes redis-volume\nError: test error" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["redis"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + return_value=["redis-volume"], +) +@mock.patch("devservices.commands.reset.down") +@mock.patch("devservices.commands.reset.stop_containers") +@mock.patch("devservices.commands.reset.remove_docker_resources") +def test_reset_with_service_name( + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_down: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="redis") + service_path = tmp_path / "code" / "test-service" + config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service", + "dependencies": { + "redis": {"description": "Redis"}, + "clickhouse": {"description": "Clickhouse"}, + }, + "modes": {"default": ["redis", "clickhouse"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "clickhouse": { + "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" + }, + }, + } + create_config_file(service_path, config) + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path / "code"), + ), + ): + state = State() + state.update_service_entry( + "test-service", "default", StateTables.STARTED_SERVICES + ) + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=redis"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["redis"]) + mock_down.assert_called_once_with( + Namespace(service_name="test-service", exclude_local=True) + ) + mock_stop_containers.assert_called_once_with(["redis"], should_remove=True) + mock_remove_docker_resources.assert_called_once_with("volume", ["redis-volume"]) + + captured = capsys.readouterr() + assert "Resetting docker volumes for redis" in captured.out + assert "Docker volumes have been reset for redis" in captured.out + assert "Bringing down test-service in order to safely reset redis" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["redis"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + return_value=["redis-volume"], +) +@mock.patch("devservices.commands.reset.down") +@mock.patch("devservices.commands.reset.stop_containers") +@mock.patch("devservices.commands.reset.remove_docker_resources") +def test_reset_with_multiple_services_depending_on_same_service( + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_down: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="redis") + service_1_path = tmp_path / "code" / "test-service-1" + service_1_config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service-1", + "dependencies": { + "redis": {"description": "Redis"}, + "clickhouse": {"description": "Clickhouse"}, + }, + "modes": {"default": ["redis", "clickhouse"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "clickhouse": { + "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" + }, + }, + } + service_2_path = tmp_path / "code" / "test-service-2" + service_2_config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service-2", + "dependencies": { + "redis": {"description": "Redis"}, + }, + "modes": {"default": ["redis"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + create_config_file(service_1_path, service_1_config) + create_config_file(service_2_path, service_2_config) + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path / "code"), + ), + ): + state = State() + state.update_service_entry( + "test-service-1", "default", StateTables.STARTED_SERVICES + ) + state.update_service_entry( + "test-service-2", "default", StateTables.STARTED_SERVICES + ) + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=redis"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["redis"]) + mock_down.assert_has_calls( + [ + mock.call(Namespace(service_name="test-service-1", exclude_local=True)), + mock.call(Namespace(service_name="test-service-2", exclude_local=True)), + ], + any_order=True, + ) + mock_stop_containers.assert_called_once_with(["redis"], should_remove=True) + mock_remove_docker_resources.assert_called_once_with("volume", ["redis-volume"]) + + captured = capsys.readouterr() + assert "Resetting docker volumes for redis" in captured.out + assert "Docker volumes have been reset for redis" in captured.out + assert "Bringing down test-service-1 in order to safely reset redis" in captured.out + assert "Bringing down test-service-2 in order to safely reset redis" in captured.out + + +@mock.patch( + "devservices.commands.reset.get_matching_containers", return_value=["clickhouse"] +) +@mock.patch( + "devservices.commands.reset.get_volumes_for_containers", + return_value=["clickhouse-volume"], +) +@mock.patch("devservices.commands.reset.down") +@mock.patch("devservices.commands.reset.stop_containers") +@mock.patch("devservices.commands.reset.remove_docker_resources") +def test_reset_with_multiple_services_depending_on_different_service( + mock_remove_docker_resources: mock.Mock, + mock_stop_containers: mock.Mock, + mock_down: mock.Mock, + mock_get_volumes_for_containers: mock.Mock, + mock_get_matching_containers: mock.Mock, + tmp_path: Path, + capsys: pytest.CaptureFixture[str], +) -> None: + args = Namespace(service_name="clickhouse") + service_1_path = tmp_path / "code" / "test-service-1" + service_1_config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service-1", + "dependencies": { + "redis": {"description": "Redis"}, + "clickhouse": {"description": "Clickhouse"}, + }, + "modes": {"default": ["redis", "clickhouse"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + "clickhouse": { + "image": "altinity/clickhouse-server:23.8.11.29.altinitystable" + }, + }, + } + service_2_path = tmp_path / "code" / "test-service-2" + service_2_config = { + "x-sentry-service-config": { + "version": 0.1, + "service_name": "test-service-2", + "dependencies": { + "redis": {"description": "Redis"}, + }, + "modes": {"default": ["redis"]}, + }, + "services": { + "redis": {"image": "redis:6.2.14-alpine"}, + }, + } + create_config_file(service_1_path, service_1_config) + create_config_file(service_2_path, service_2_config) + with ( + mock.patch("devservices.utils.state.STATE_DB_FILE", str(tmp_path / "state")), + mock.patch( + "devservices.utils.services.get_coderoot", + return_value=str(tmp_path / "code"), + ), + ): + state = State() + state.update_service_entry( + "test-service-1", "default", StateTables.STARTED_SERVICES + ) + state.update_service_entry( + "test-service-2", "default", StateTables.STARTED_SERVICES + ) + reset(args) + + mock_get_matching_containers.assert_called_once_with( + [DEVSERVICES_ORCHESTRATOR_LABEL, "com.docker.compose.service=clickhouse"] + ) + mock_get_volumes_for_containers.assert_called_once_with(["clickhouse"]) + mock_down.assert_called_once_with( + Namespace(service_name="test-service-1", exclude_local=True) + ) + mock_stop_containers.assert_called_once_with(["clickhouse"], should_remove=True) + mock_remove_docker_resources.assert_called_once_with( + "volume", ["clickhouse-volume"] + ) + + captured = capsys.readouterr() + assert "Resetting docker volumes for clickhouse" in captured.out + assert "Docker volumes have been reset for clickhouse" in captured.out + assert ( + "Bringing down test-service-1 in order to safely reset clickhouse" + in captured.out + ) diff --git a/tests/utils/test_docker.py b/tests/utils/test_docker.py index 199a35b4..18b7dcef 100644 --- a/tests/utils/test_docker.py +++ b/tests/utils/test_docker.py @@ -56,7 +56,7 @@ def test_get_matching_containers( ) -> None: mock_check_docker_daemon_running.return_value = None mock_check_output.return_value = "container1\ncontainer2" - matching_containers = get_matching_containers(DEVSERVICES_ORCHESTRATOR_LABEL) + matching_containers = get_matching_containers([DEVSERVICES_ORCHESTRATOR_LABEL]) mock_check_docker_daemon_running.assert_called_once() mock_check_output.assert_called_once_with( [ @@ -107,7 +107,7 @@ def test_get_matching_containers_docker_daemon_not_running( ) -> None: mock_check_docker_daemon_running.side_effect = DockerDaemonNotRunningError() with pytest.raises(DockerDaemonNotRunningError): - get_matching_containers(DEVSERVICES_ORCHESTRATOR_LABEL) + get_matching_containers([DEVSERVICES_ORCHESTRATOR_LABEL]) mock_check_docker_daemon_running.assert_called_once() mock_check_output.assert_not_called() @@ -134,7 +134,7 @@ def test_get_matching_containers_error( mock_check_docker_daemon_running.return_value = None mock_check_output.side_effect = subprocess.CalledProcessError(1, "cmd") with pytest.raises(DockerError): - get_matching_containers(DEVSERVICES_ORCHESTRATOR_LABEL) + get_matching_containers([DEVSERVICES_ORCHESTRATOR_LABEL]) mock_check_docker_daemon_running.assert_called_once() mock_check_output.assert_called_once_with( [ @@ -236,7 +236,7 @@ def test_stop_containers_should_not_remove( ["docker", "stop", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ) @@ -260,13 +260,13 @@ def test_stop_containers_should_remove( ["docker", "stop", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ), mock.call( ["docker", "container", "rm", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ), ] ) @@ -284,7 +284,7 @@ def test_stop_containers_stop_error( ["docker", "stop", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ) @@ -302,13 +302,13 @@ def test_stop_containers_remove_error( ["docker", "stop", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ), mock.call( ["docker", "container", "rm", *containers], check=True, stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, + stderr=subprocess.PIPE, ), ] )