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
51 changes: 47 additions & 4 deletions src/devctl/adapters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
import re
import subprocess
import sys

Expand All @@ -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')}"
Expand Down Expand Up @@ -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"]
Expand Down Expand Up @@ -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,
Expand All @@ -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:
Expand Down
22 changes: 16 additions & 6 deletions src/devctl/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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:
Expand All @@ -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 ""))
Expand Down Expand Up @@ -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:
Expand Down
67 changes: 67 additions & 0 deletions tests/test_adapters.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import json
import os
from io import StringIO
from types import SimpleNamespace
from unittest.mock import patch

Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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)
18 changes: 9 additions & 9 deletions tests/test_commands_gc.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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)
Expand All @@ -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="")
Expand All @@ -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="")
Expand All @@ -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):
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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:
Expand All @@ -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()):
Expand Down
14 changes: 10 additions & 4 deletions tests/test_doctor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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])
Loading