Some checks failed
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / build-and-deploy (push) Has been cancelled
CD Pipeline / post-deploy-checks (push) Has been cancelled
CD Pipeline / tests (push) Has been cancelled
193 lines
5.8 KiB
Python
193 lines
5.8 KiB
Python
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",
|
|
]
|