from __future__ import annotations import importlib.util import sys from datetime import datetime, timedelta, timezone from pathlib import Path from types import SimpleNamespace ROOT = Path(__file__).resolve().parents[3] SCRIPT = ROOT / "scripts" / "ops" / "docker-disk-pressure-retention-cleanup.py" spec = importlib.util.spec_from_file_location("docker_disk_pressure_retention_cleanup", SCRIPT) module = importlib.util.module_from_spec(spec) assert spec and spec.loader sys.modules[spec.name] = module spec.loader.exec_module(module) UTC = timezone.utc # noqa: UP017 - mirrors supported Python 3.9 runtime. def image(image_id: str, created_at: datetime, tags: tuple[str, ...] = ()): return module.ImageInfo( image_id=image_id, created_at=created_at, size_bytes=1024, repo_tags=tags, ) def test_select_dangling_images_keeps_newest_and_protects_running_images() -> None: now = datetime(2026, 7, 1, 12, tzinfo=UTC) images = [ image("sha256:running", now - timedelta(hours=72)), image("oldest", now - timedelta(hours=72)), image("middle", now - timedelta(hours=48)), image("newest", now - timedelta(hours=30)), image("too_recent", now - timedelta(hours=2)), image("tagged", now - timedelta(hours=72), ("repo:tag",)), ] selected = module.select_dangling_image_removals( images, {"running"}, now=now, min_age_hours=24, keep_newest=1, ) assert [item.image_id for item in selected] == ["oldest", "middle"] def test_builder_prune_command_is_bounded_by_age_and_keep_storage() -> None: args = SimpleNamespace( docker_bin="docker", min_age_hours=36, builder_keep_storage="40GB", ) assert module.builder_prune_command(args) == [ "docker", "builder", "prune", "--force", "--filter", "until=36h", "--keep-storage", "40GB", ] def test_parse_docker_datetime_accepts_nanosecond_fraction() -> None: parsed = module.parse_docker_datetime("2026-07-01T23:29:21.919867918+08:00") assert parsed.isoformat() == "2026-07-01T15:29:21.919867+00:00" def test_summary_never_reports_volumes_or_container_cleanup_boundary() -> None: now = datetime(2026, 7, 1, 12, tzinfo=UTC) selected = module.select_dangling_image_removals( [image("old", now - timedelta(days=3))], set(), now=now, min_age_hours=24, keep_newest=0, ) summary = module.summarize_images(selected) assert summary["count"] == 1 assert summary["sample_image_ids"] == ["old"] def test_tagged_cleanup_preserves_running_and_repository_rollback_window() -> None: now = datetime(2026, 7, 14, 12, tzinfo=UTC) images = [ image("running", now - timedelta(days=30), ("registry:5000/app:running",)), image("latest", now - timedelta(days=1), ("registry:5000/app:latest",)), image("rollback-1", now - timedelta(days=2), ("registry:5000/app:sha-1",)), image("rollback-2", now - timedelta(days=3), ("registry:5000/app:sha-2",)), image("expired", now - timedelta(days=14), ("registry:5000/app:sha-old",)), image("other-repo", now - timedelta(days=14), ("registry:5000/other:only",)), ] selected = module.select_tagged_image_removals( images, {"running"}, now=now, min_age_hours=7 * 24, keep_newest_per_repository=3, ) assert [item.image_id for item in selected] == ["expired"] def test_multi_repository_image_is_retained_when_any_repository_needs_it() -> None: now = datetime(2026, 7, 14, 12, tzinfo=UTC) shared = image( "shared", now - timedelta(days=30), ("registry:5000/app:old", "registry:5000/critical:latest"), ) selected = module.select_tagged_image_removals( [shared, image("app-new", now - timedelta(days=1), ("registry:5000/app:new",))], set(), now=now, min_age_hours=7 * 24, keep_newest_per_repository=1, ) assert selected == [] def test_repository_parser_preserves_registry_port() -> None: assert module.repository_from_tag("registry:5000/team/app:sha") == "registry:5000/team/app" def test_single_apply_image_removal_is_count_bounded() -> None: now = datetime(2026, 7, 14, 12, tzinfo=UTC) candidates = [ image(f"image-{index}", now - timedelta(days=index + 1)) for index in range(5) ] selected = module.limit_image_removals(candidates, max_removals=2) assert [item.image_id for item in selected] == ["image-4", "image-3"] def test_remove_images_batches_tagged_references(monkeypatch) -> None: calls: list[list[str]] = [] def fake_docker(args: list[str], _docker_bin: str): calls.append(args) return SimpleNamespace(stdout="", stderr="", returncode=0) monkeypatch.setattr(module, "docker", fake_docker) now = datetime(2026, 7, 14, 12, tzinfo=UTC) removed = module.remove_images( [ image("dangling", now), image("tagged-a", now, ("repo-a:old", "repo-b:old")), image("tagged-b", now, ("repo-c:old",)), ], "docker", ) assert removed == ["dangling", "tagged-a", "tagged-b"] assert calls == [ ["image", "rm", "dangling"], ["image", "rm", "repo-a:old", "repo-b:old", "repo-c:old"], ] def test_cli_exposes_builder_cache_only_flag() -> None: help_text = module.run_command(["python3", str(SCRIPT), "--help"]).stdout assert "--skip-dangling-images" in help_text def test_zero_age_builder_prune_omits_until_filter() -> None: args = SimpleNamespace( docker_bin="docker", min_age_hours=0, builder_keep_storage="1GB", ) assert module.builder_prune_command(args) == [ "docker", "builder", "prune", "--force", "--keep-storage", "1GB", ]