diff --git a/src/devctl/adapters.py b/src/devctl/adapters.py index 6dcfa3c..d64ff4d 100644 --- a/src/devctl/adapters.py +++ b/src/devctl/adapters.py @@ -1,5 +1,6 @@ import json import os +import re import subprocess import sys @@ -9,6 +10,8 @@ from .routing import domain_for, url_for from .settings import Settings +ANONYMOUS_VOLUME_NAME = re.compile(r"^[0-9a-f]{64}$") + def compose_project_name(profile: ProjectProfile) -> str: return profile.compose_project_name or f"devctl_{profile.name}_{os.environ.get('USER', 'user')}" @@ -177,11 +180,28 @@ def _run_streaming(self, command: list[str], env: dict[str, str], log_file) -> i return process.wait() def down(self) -> int: - return subprocess.run( + leaked = self.anonymous_volumes() + exit_code = subprocess.run( self.command_down(), cwd=self.profile.path, env=self.env(0, [0] * len(self.profile.extra_port_envs)), ).returncode + if exit_code == 0 and leaked: + self._remove_volumes(leaked) + return exit_code + + def _remove_volumes(self, names: list[str]) -> None: + removed = 0 + for name in names: + result = subprocess.run( + ["docker", "volume", "rm", name], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + if result.returncode == 0: + removed += 1 + if removed: + print(f"Removed {removed} anonymous volume(s) left behind by {compose_project_name(self.profile)}") def command_exec(self, service: str, cmd: list[str], tty: bool) -> list[str]: args = ["docker", "compose"] @@ -224,17 +244,19 @@ def logs(self, *, follow: bool, services: list[str], since: str | None, tail: in env=self.env(0, [0] * len(self.profile.extra_port_envs)), ).returncode - def command_ps_ids(self) -> list[str]: + def command_ps_ids(self, *, all_containers: bool = False) -> list[str]: args = ["docker", "compose"] if self.profile.env_file: args.extend(["--env-file", self.profile.env_file]) args.extend(self._compose_file_args()) args.extend(["ps", "-q"]) + if all_containers: + args.append("-a") return args - def container_ids(self) -> list[str]: + def container_ids(self, *, all_containers: bool = False) -> list[str]: result = subprocess.run( - self.command_ps_ids(), + self.command_ps_ids(all_containers=all_containers), cwd=self.profile.path, env=self.env(0, [0] * len(self.profile.extra_port_envs)), stdout=subprocess.PIPE, @@ -243,6 +265,27 @@ def container_ids(self) -> list[str]: ) return [line.strip() for line in result.stdout.splitlines() if line.strip()] + def anonymous_volumes(self) -> list[str]: + container_ids = self.container_ids(all_containers=True) + if not container_ids: + return [] + result = subprocess.run( + [ + "docker", + "inspect", + "--format", + '{{range .Mounts}}{{if eq .Type "volume"}}{{println .Name}}{{end}}{{end}}', + *container_ids, + ], + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + text=True, + ) + if result.returncode != 0: + return [] + names = {line.strip() for line in result.stdout.splitlines() if line.strip()} + return sorted(name for name in names if ANONYMOUS_VOLUME_NAME.fullmatch(name)) + def adapter_for(settings: Settings, profile: ProjectProfile) -> DockerComposeAdapter: if profile.adapter != DockerComposeAdapter.name: diff --git a/src/devctl/commands.py b/src/devctl/commands.py index 6a18a30..a8d9ccd 100644 --- a/src/devctl/commands.py +++ b/src/devctl/commands.py @@ -737,9 +737,19 @@ def _warn_upstream_publish_mismatch(settings: Settings) -> None: ) -def _list_dangling_volumes() -> list[str]: +def _list_orphan_anonymous_volumes() -> list[str]: result = subprocess.run( - ["docker", "volume", "ls", "-f", "dangling=true", "--format", "{{.Name}}"], + [ + "docker", + "volume", + "ls", + "-f", + "dangling=true", + "-f", + "label=com.docker.volume.anonymous", + "--format", + "{{.Name}}", + ], stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, text=True, @@ -803,7 +813,7 @@ def gc(*, dry_run: bool, yes: bool, volumes: bool, images: bool) -> None: if not volumes and not images: volumes = True - candidate_volumes = _list_dangling_volumes() if volumes else [] + candidate_volumes = _list_orphan_anonymous_volumes() if volumes else [] candidate_images = _list_dangling_images() if images else [] if not candidate_volumes and not candidate_images: @@ -813,7 +823,7 @@ def gc(*, dry_run: bool, yes: bool, volumes: bool, images: bool) -> None: sizes = _system_df_volume_sizes() if candidate_volumes else {} if candidate_volumes: - print(info(f"{len(candidate_volumes)} orphan volume(s):")) + print(info(f"{len(candidate_volumes)} orphan anonymous volume(s):")) for name in candidate_volumes: size = sizes.get(name) print(f" - {name}" + (f" ({size})" if size else "")) @@ -867,10 +877,10 @@ def gc(*, dry_run: bool, yes: bool, volumes: bool, images: bool) -> None: def _warn_orphan_volumes() -> None: - names = _list_dangling_volumes() + names = _list_orphan_anonymous_volumes() if not names: return - print(warning(f"note: {len(names)} orphan docker volume(s) not attached to any container:")) + print(warning(f"note: {len(names)} anonymous docker volume(s) left behind by a removed container:")) for name in names[:10]: print(f" - {name}") if len(names) > 10: diff --git a/tests/test_adapters.py b/tests/test_adapters.py index 12aae83..7a92ec0 100644 --- a/tests/test_adapters.py +++ b/tests/test_adapters.py @@ -1,5 +1,6 @@ import json import os +from io import StringIO from types import SimpleNamespace from unittest.mock import patch @@ -165,6 +166,15 @@ def test_command_ps_ids_includes_env_file(self) -> None: adapter.command_ps_ids(), ) + def test_command_ps_ids_can_include_stopped_containers(self) -> None: + profile = make_profile(compose_file="compose.yml") + adapter = DockerComposeAdapter(self.settings, profile) + + self.assertEqual( + ["docker", "compose", "-f", "compose.yml", "ps", "-q", "-a"], + adapter.command_ps_ids(all_containers=True), + ) + @patch("devctl.adapters.subprocess.run") def test_container_ids_parses_stdout_lines(self, run_mock) -> None: run_mock.return_value.stdout = "abc123\ndef456\n" @@ -290,3 +300,60 @@ def test_docker_failure_and_garbage_degrade_to_no_reclaimable_ports(self, run_mo run_mock.return_value = self._result(0, "") self.assertEqual(set(), published_ports("devctl_app_alice")) + + +ANONYMOUS_A = "a" * 64 +ANONYMOUS_B = "b" * 64 + + +class AdapterAnonymousVolumeTests(TempDirTestCase): + def setUp(self) -> None: + super().setUp() + self.settings = make_settings(self.tmp_path) + self.adapter = DockerComposeAdapter(self.settings, make_profile(compose_file="compose.yml")) + + @patch("devctl.adapters.subprocess.run") + def test_only_unnamed_volumes_are_collected(self, run_mock) -> None: + run_mock.side_effect = [ + SimpleNamespace(returncode=0, stdout="c1\nc2\n"), + SimpleNamespace(returncode=0, stdout=f"{ANONYMOUS_A}\napp_db\n{ANONYMOUS_B}\napp_db\n"), + ] + + self.assertEqual([ANONYMOUS_A, ANONYMOUS_B], self.adapter.anonymous_volumes()) + + @patch("devctl.adapters.subprocess.run") + def test_no_containers_means_nothing_to_collect(self, run_mock) -> None: + run_mock.return_value = SimpleNamespace(returncode=0, stdout="") + + self.assertEqual([], self.adapter.anonymous_volumes()) + self.assertEqual(1, run_mock.call_count) + + @patch("devctl.adapters.subprocess.run") + def test_down_removes_anonymous_volumes_and_spares_named_ones(self, run_mock) -> None: + run_mock.side_effect = [ + SimpleNamespace(returncode=0, stdout="c1\n"), + SimpleNamespace(returncode=0, stdout=f"{ANONYMOUS_A}\napp_db\n"), + SimpleNamespace(returncode=0, stdout=""), + SimpleNamespace(returncode=0, stdout=""), + ] + + with patch("sys.stdout", new=StringIO()): + self.assertEqual(0, self.adapter.down()) + + calls = [call.args[0] for call in run_mock.call_args_list] + self.assertIn(["docker", "volume", "rm", ANONYMOUS_A], calls) + self.assertNotIn(["docker", "volume", "rm", "app_db"], calls) + + @patch("devctl.adapters.subprocess.run") + def test_a_failed_down_removes_nothing(self, run_mock) -> None: + run_mock.side_effect = [ + SimpleNamespace(returncode=0, stdout="c1\n"), + SimpleNamespace(returncode=0, stdout=f"{ANONYMOUS_A}\n"), + SimpleNamespace(returncode=1, stdout=""), + ] + + with patch("sys.stdout", new=StringIO()): + self.assertEqual(1, self.adapter.down()) + + calls = [call.args[0] for call in run_mock.call_args_list] + self.assertNotIn(["docker", "volume", "rm", ANONYMOUS_A], calls) diff --git a/tests/test_commands_gc.py b/tests/test_commands_gc.py index 0cf25e7..d69e02d 100644 --- a/tests/test_commands_gc.py +++ b/tests/test_commands_gc.py @@ -47,7 +47,7 @@ def test_empty_when_docker_missing(self, run_mock) -> None: class GcDryRunTests(TempDirTestCase): @patch("devctl.commands._system_df_volume_sizes", return_value={}) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a", "vol_b"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a", "vol_b"]) @patch("devctl.commands.subprocess.run") def test_dry_run_prints_and_removes_nothing(self, run_mock, *_mocks) -> None: with patch("sys.stdout", new=StringIO()) as out: @@ -57,7 +57,7 @@ def test_dry_run_prints_and_removes_nothing(self, run_mock, *_mocks) -> None: self.assertIn("vol_b", out.getvalue()) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=[]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=[]) def test_nothing_to_clean_up(self, *_mocks) -> None: with patch("sys.stdout", new=StringIO()) as out: gc(dry_run=False, yes=True, volumes=True, images=False) @@ -66,7 +66,7 @@ def test_nothing_to_clean_up(self, *_mocks) -> None: class GcYesModeTests(TempDirTestCase): @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a", "vol_b"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a", "vol_b"]) @patch("devctl.commands.subprocess.run") def test_yes_removes_all_without_prompt(self, run_mock, *_mocks) -> None: run_mock.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") @@ -77,7 +77,7 @@ def test_yes_removes_all_without_prompt(self, run_mock, *_mocks) -> None: self.assertIn(["docker", "volume", "rm", "vol_b"], calls) @patch("devctl.commands._list_dangling_images", return_value=[("img1", "myapp:latest")]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a"]) @patch("devctl.commands.subprocess.run") def test_images_only_leaves_volumes_untouched(self, run_mock, *_mocks) -> None: run_mock.return_value = SimpleNamespace(returncode=0, stdout="", stderr="") @@ -88,7 +88,7 @@ def test_images_only_leaves_volumes_untouched(self, run_mock, *_mocks) -> None: self.assertNotIn(["docker", "volume", "rm", "vol_a"], calls) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a", "vol_b"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a", "vol_b"]) @patch("devctl.commands.subprocess.run") def test_partial_failure_raises_and_reports(self, run_mock, *_mocks) -> None: def side_effect(cmd, **kwargs): @@ -106,7 +106,7 @@ def side_effect(cmd, **kwargs): class GcInteractivePromptTests(TempDirTestCase): @patch("devctl.commands._system_df_volume_sizes", return_value={}) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a"]) @patch("devctl.commands.subprocess.run") @patch("devctl.commands.sys.stdin") def test_tty_accept_removes(self, stdin_mock, run_mock, *_mocks) -> None: @@ -119,7 +119,7 @@ def test_tty_accept_removes(self, stdin_mock, run_mock, *_mocks) -> None: @patch("devctl.commands._system_df_volume_sizes", return_value={}) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a"]) @patch("devctl.commands.subprocess.run") @patch("devctl.commands.sys.stdin") def test_tty_decline_removes_nothing(self, stdin_mock, run_mock, *_mocks) -> None: @@ -131,7 +131,7 @@ def test_tty_decline_removes_nothing(self, stdin_mock, run_mock, *_mocks) -> Non @patch("devctl.commands._system_df_volume_sizes", return_value={}) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a"]) @patch("devctl.commands.subprocess.run") @patch("devctl.commands.sys.stdin") def test_non_tty_no_yes_behaves_like_dry_run(self, stdin_mock, run_mock, *_mocks) -> None: @@ -142,7 +142,7 @@ def test_non_tty_no_yes_behaves_like_dry_run(self, stdin_mock, run_mock, *_mocks @patch("devctl.commands._system_df_volume_sizes", return_value={}) @patch("devctl.commands._list_dangling_images", return_value=[]) - @patch("devctl.commands._list_dangling_volumes", return_value=["vol_a"]) + @patch("devctl.commands._list_orphan_anonymous_volumes", return_value=["vol_a"]) @patch("devctl.commands.subprocess.run") def test_dry_run_wins_even_with_yes(self, run_mock, *_mocks) -> None: with patch("sys.stdout", new=StringIO()): diff --git a/tests/test_doctor.py b/tests/test_doctor.py index bc4a1d9..e60c504 100644 --- a/tests/test_doctor.py +++ b/tests/test_doctor.py @@ -5,7 +5,7 @@ from types import SimpleNamespace from unittest.mock import patch -from devctl.commands import _list_dangling_volumes, _warn_upstream_publish_mismatch, doctor +from devctl.commands import _list_orphan_anonymous_volumes, _warn_upstream_publish_mismatch, doctor from devctl.errors import DevctlError from .helpers import TempDirTestCase, make_settings @@ -37,13 +37,19 @@ def test_doctor_json_emits_object_and_still_raises_on_failure(self) -> None: self.assertIn("docker", payload) -class ListDanglingVolumesTests(unittest.TestCase): +class ListOrphanAnonymousVolumesTests(unittest.TestCase): @patch("devctl.commands.subprocess.run") def test_parses_volume_names(self, run_mock) -> None: run_mock.return_value = SimpleNamespace(returncode=0, stdout="vol_a\nvol_b\n") - self.assertEqual(["vol_a", "vol_b"], _list_dangling_volumes()) + self.assertEqual(["vol_a", "vol_b"], _list_orphan_anonymous_volumes()) @patch("devctl.commands.subprocess.run") def test_empty_when_docker_fails(self, run_mock) -> None: run_mock.return_value = SimpleNamespace(returncode=1, stdout="") - self.assertEqual([], _list_dangling_volumes()) + self.assertEqual([], _list_orphan_anonymous_volumes()) + + @patch("devctl.commands.subprocess.run") + def test_filters_on_the_anonymous_label_so_named_volumes_are_never_listed(self, run_mock) -> None: + run_mock.return_value = SimpleNamespace(returncode=0, stdout="") + _list_orphan_anonymous_volumes() + self.assertIn("label=com.docker.volume.anonymous", run_mock.call_args.args[0])