Skip to content

Commit a9aa45a

Browse files
fix: images clean matches registry tags, not capy prefix (#99)
* fix: images clean uses registry tags instead of capy-ubuntu- prefix * style: sort imports in test_images_paths.py * Add registry before docker note
1 parent b94701e commit a9aa45a

2 files changed

Lines changed: 322 additions & 6 deletions

File tree

cli/localci/cli/images.py

Lines changed: 29 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -226,20 +226,45 @@ def images_build(
226226
# ---------------------------------------------------------------------------
227227

228228

229+
def _cleanup_targets(installed_tags: list[str], registry_tags: set[str]) -> list[str]:
230+
"""Installed repo:tags that are also in the registry, sorted."""
231+
return sorted(tag for tag in installed_tags if tag in registry_tags)
232+
233+
229234
@images.command("clean")
230235
@click.option("--all", "clean_all", is_flag=True, help="Remove all localci images.")
231236
@click.option("--dry-run", is_flag=True, help="Preview without removing.")
237+
@click.option(
238+
"--registry",
239+
"-r",
240+
"registry_path",
241+
type=click.Path(path_type=Path, exists=True),
242+
default=None,
243+
help="Path to image-registry.yml.",
244+
)
232245
@click.pass_context
233246
def images_clean(
234247
ctx: click.Context,
235248
clean_all: bool,
236249
dry_run: bool,
250+
registry_path: Path | None,
237251
) -> None:
238252
"""Clean up Docker images."""
239253
if not clean_all:
240254
print_info("Nothing to clean. Use --all to remove localci images.")
241255
return
242256

257+
try:
258+
registry = _get_registry(registry_path)
259+
except FileNotFoundError as exc:
260+
print_error(str(exc))
261+
ctx.exit(1)
262+
return
263+
except (OSError, yaml.YAMLError, TypeError, ValueError) as exc:
264+
print_error(f"Could not load image registry: {exc}")
265+
ctx.exit(1)
266+
return
267+
243268
try:
244269
dm = DockerManager()
245270
except DockerNotAvailableError as exc:
@@ -253,13 +278,11 @@ def images_clean(
253278
ctx.exit(result.returncode)
254279
return
255280

256-
targets = [
257-
line.strip()
258-
for line in result.stdout.splitlines()
259-
if line.strip().startswith("capy-ubuntu-")
260-
]
281+
registry_tags = {e.docker_tag for e in registry.entries if e.docker_tag}
282+
installed = [line.strip() for line in result.stdout.splitlines() if line.strip()]
283+
targets = _cleanup_targets(installed, registry_tags)
261284
if not targets:
262-
print_info("No localci capy images found.")
285+
print_info("No localci images found.")
263286
return
264287

265288
if dry_run:

cli/tests/test_images_paths.py

Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import pytest
99
from click.testing import CliRunner
1010

11+
from localci.cli.images import _cleanup_targets
1112
from localci.cli.main import cli
1213
from localci.utils.paths import (
1314
IMAGES_CAPY_REL,
@@ -250,3 +251,295 @@ def fake_run(cmd: list[str]) -> MagicMock:
250251

251252
assert result.exit_code == 0
252253
assert captured[0][1] == str(images_dir / "build-all.sh")
254+
255+
256+
def _docker_run_side_effect(
257+
installed_tags: list[str],
258+
*,
259+
captured_rmi: list[list[str]] | None = None,
260+
):
261+
"""Stub _run for clean tests: fake image ls output, record rmi argv."""
262+
263+
def fake_run(cmd: list[str]) -> MagicMock:
264+
if "image" in cmd and "ls" in cmd:
265+
stdout = "\n".join(installed_tags)
266+
if stdout:
267+
stdout += "\n"
268+
return MagicMock(returncode=0, stdout=stdout, stderr="")
269+
if "rmi" in cmd:
270+
if captured_rmi is not None:
271+
captured_rmi.append(cmd)
272+
return MagicMock(returncode=0, stdout="", stderr="")
273+
return MagicMock(returncode=0, stdout="", stderr="")
274+
275+
return fake_run
276+
277+
278+
class TestCleanupTargets:
279+
def test_intersection_sorted(self) -> None:
280+
installed = ["b:tag", "a:tag", "other:tag"]
281+
registry = {"a:tag", "b:tag", "registry-only:tag"}
282+
assert _cleanup_targets(installed, registry) == ["a:tag", "b:tag"]
283+
284+
def test_empty_when_no_match(self) -> None:
285+
assert _cleanup_targets(["other:tag"], {"ubuntu-latest-gcc15:latest"}) == []
286+
287+
288+
class TestImagesClean:
289+
def test_clean_generic_tag_removes_registry_match_only(
290+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
291+
) -> None:
292+
repo = tmp_path / "repo"
293+
registry = _write_registry_with_image(
294+
repo, docker_tag="ubuntu-latest-gcc15:latest"
295+
)
296+
outside = tmp_path / "outside"
297+
outside.mkdir()
298+
monkeypatch.chdir(outside)
299+
300+
captured_rmi: list[list[str]] = []
301+
fake_dm = MagicMock()
302+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
303+
304+
with (
305+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
306+
patch(
307+
"localci.cli.images._run",
308+
side_effect=_docker_run_side_effect(
309+
["ubuntu-latest-gcc15:latest", "other:tag"],
310+
captured_rmi=captured_rmi,
311+
),
312+
),
313+
):
314+
result = runner.invoke(
315+
cli,
316+
["images", "clean", "--all", "--registry", str(registry)],
317+
)
318+
319+
assert result.exit_code == 0
320+
assert len(captured_rmi) == 1
321+
assert captured_rmi[0][-1] == "ubuntu-latest-gcc15:latest"
322+
assert "other:tag" not in captured_rmi[0]
323+
324+
def test_clean_capy_tag_regression(
325+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
326+
) -> None:
327+
repo = tmp_path / "repo"
328+
registry = _write_registry_with_image(
329+
repo, docker_tag="capy-ubuntu-25.04-gcc15:latest"
330+
)
331+
outside = tmp_path / "outside"
332+
outside.mkdir()
333+
monkeypatch.chdir(outside)
334+
335+
captured_rmi: list[list[str]] = []
336+
fake_dm = MagicMock()
337+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
338+
339+
with (
340+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
341+
patch(
342+
"localci.cli.images._run",
343+
side_effect=_docker_run_side_effect(
344+
[
345+
"capy-ubuntu-25.04-gcc15:latest",
346+
"capy-ubuntu-orphan:latest",
347+
"unrelated:tag",
348+
],
349+
captured_rmi=captured_rmi,
350+
),
351+
),
352+
):
353+
result = runner.invoke(
354+
cli,
355+
["images", "clean", "--all", "--registry", str(registry)],
356+
)
357+
358+
assert result.exit_code == 0
359+
assert len(captured_rmi) == 1
360+
assert captured_rmi[0][-1] == "capy-ubuntu-25.04-gcc15:latest"
361+
assert "capy-ubuntu-orphan:latest" not in captured_rmi[0]
362+
assert "unrelated:tag" not in captured_rmi[0]
363+
364+
def test_clean_dry_run_lists_targets_without_rmi(
365+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
366+
) -> None:
367+
repo = tmp_path / "repo"
368+
registry = _write_registry_with_image(
369+
repo, docker_tag="ubuntu-latest-gcc15:latest"
370+
)
371+
outside = tmp_path / "outside"
372+
outside.mkdir()
373+
monkeypatch.chdir(outside)
374+
375+
captured_rmi: list[list[str]] = []
376+
fake_dm = MagicMock()
377+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
378+
379+
with (
380+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
381+
patch(
382+
"localci.cli.images._run",
383+
side_effect=_docker_run_side_effect(
384+
["ubuntu-latest-gcc15:latest", "other:tag"],
385+
captured_rmi=captured_rmi,
386+
),
387+
),
388+
):
389+
result = runner.invoke(
390+
cli,
391+
[
392+
"images",
393+
"clean",
394+
"--all",
395+
"--dry-run",
396+
"--registry",
397+
str(registry),
398+
],
399+
)
400+
401+
assert result.exit_code == 0
402+
assert "ubuntu-latest-gcc15:latest" in result.output
403+
assert "other:tag" not in result.output
404+
assert "Dry-run" in result.output
405+
assert captured_rmi == []
406+
407+
def test_clean_without_all_does_not_invoke_docker(
408+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
409+
) -> None:
410+
outside = tmp_path / "outside"
411+
outside.mkdir()
412+
monkeypatch.chdir(outside)
413+
414+
with patch("localci.cli.images._run") as mock_run:
415+
result = runner.invoke(cli, ["images", "clean"])
416+
417+
mock_run.assert_not_called()
418+
assert result.exit_code == 0
419+
assert "Nothing to clean" in result.output
420+
421+
def test_clean_empty_intersection_reports_no_images(
422+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
423+
) -> None:
424+
repo = tmp_path / "repo"
425+
registry = _write_registry_with_image(
426+
repo, docker_tag="ubuntu-latest-gcc15:latest"
427+
)
428+
outside = tmp_path / "outside"
429+
outside.mkdir()
430+
monkeypatch.chdir(outside)
431+
432+
captured_rmi: list[list[str]] = []
433+
fake_dm = MagicMock()
434+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
435+
436+
with (
437+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
438+
patch(
439+
"localci.cli.images._run",
440+
side_effect=_docker_run_side_effect(
441+
["other:tag"],
442+
captured_rmi=captured_rmi,
443+
),
444+
),
445+
):
446+
result = runner.invoke(
447+
cli,
448+
["images", "clean", "--all", "--registry", str(registry)],
449+
)
450+
451+
assert result.exit_code == 0
452+
assert "No localci images found" in result.output
453+
assert captured_rmi == []
454+
455+
def test_clean_uses_explicit_registry(
456+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
457+
) -> None:
458+
repo = tmp_path / "repo"
459+
registry = _write_registry_with_image(repo, docker_tag="explicit:tag")
460+
outside = tmp_path / "outside"
461+
outside.mkdir()
462+
monkeypatch.chdir(outside)
463+
464+
captured_rmi: list[list[str]] = []
465+
fake_dm = MagicMock()
466+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
467+
468+
with (
469+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
470+
patch(
471+
"localci.cli.images._run",
472+
side_effect=_docker_run_side_effect(
473+
["explicit:tag"],
474+
captured_rmi=captured_rmi,
475+
),
476+
),
477+
):
478+
result = runner.invoke(
479+
cli,
480+
["images", "clean", "--all", "--registry", str(registry)],
481+
)
482+
483+
assert result.exit_code == 0
484+
assert captured_rmi[0][-1] == "explicit:tag"
485+
486+
def test_clean_discovers_registry_from_cwd(
487+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
488+
) -> None:
489+
repo = tmp_path / "repo"
490+
nested = repo / "examples" / "validation-project"
491+
nested.mkdir(parents=True)
492+
_write_registry_with_image(repo, docker_tag="ubuntu-latest-gcc15:latest")
493+
monkeypatch.chdir(nested)
494+
495+
captured_rmi: list[list[str]] = []
496+
fake_dm = MagicMock()
497+
fake_dm.build_cmd.side_effect = lambda *args: ["docker", *args]
498+
499+
with (
500+
patch("localci.cli.images.DockerManager", return_value=fake_dm),
501+
patch(
502+
"localci.cli.images._run",
503+
side_effect=_docker_run_side_effect(
504+
["ubuntu-latest-gcc15:latest", "other:tag"],
505+
captured_rmi=captured_rmi,
506+
),
507+
),
508+
):
509+
result = runner.invoke(cli, ["images", "clean", "--all"])
510+
511+
assert result.exit_code == 0
512+
assert len(captured_rmi) == 1
513+
assert captured_rmi[0][-1] == "ubuntu-latest-gcc15:latest"
514+
assert "other:tag" not in captured_rmi[0]
515+
516+
def test_clean_all_without_discoverable_registry_fails_before_docker(
517+
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
518+
) -> None:
519+
site_module = (
520+
tmp_path
521+
/ "venv"
522+
/ "lib"
523+
/ "python3.10"
524+
/ "site-packages"
525+
/ "localci"
526+
/ "cli"
527+
/ "images.py"
528+
)
529+
site_module.parent.mkdir(parents=True, exist_ok=True)
530+
site_module.write_text("# installed copy\n", encoding="utf-8")
531+
outside = tmp_path / "outside"
532+
outside.mkdir()
533+
monkeypatch.chdir(outside)
534+
535+
with (
536+
patch("localci.cli.images._IMAGES_MODULE", site_module),
537+
patch("localci.cli.images.DockerManager") as mock_dm,
538+
patch("localci.cli.images._run") as mock_run,
539+
):
540+
result = runner.invoke(cli, ["images", "clean", "--all"])
541+
542+
assert result.exit_code == 1
543+
assert REGISTRY_FILENAME in result.output
544+
mock_dm.assert_not_called()
545+
mock_run.assert_not_called()

0 commit comments

Comments
 (0)