diff --git a/fixos/cli/_cleanup_space.py b/fixos/cli/_cleanup_space.py index 3987039..57f1b05 100644 --- a/fixos/cli/_cleanup_space.py +++ b/fixos/cli/_cleanup_space.py @@ -1162,7 +1162,7 @@ def _docker_buildx_builders() -> list[dict] | None: return builders -def _builder_cache_bytes(name: str) -> tuple[int, int] | None: +def _builder_cache_bytes(name: str) -> tuple[int, int | None] | None: """Return (total, reclaimable) bytes of a builder's cache or None.""" try: result = _run_cmd( @@ -1183,23 +1183,62 @@ def _builder_cache_bytes(name: str) -> tuple[int, int] | None: reclaimable = _size_to_bytes(match.group(2)) if total is None: return None - return total, reclaimable if reclaimable is not None else total + return total, reclaimable + + +def _container_image_ids() -> set[str] | None: + """Read image references for running and stopped containers, or unknown.""" + try: + result = _run_cmd( + ["docker", "container", "ls", "--all", "--quiet", "--no-trunc"] + ) + if result.returncode != 0: + return None + containers = result.stdout.split() + if not containers: + return set() + if any(not re.fullmatch(r"[0-9a-f]{64}", item) for item in containers): + return None + result = _run_cmd( + ["docker", "container", "inspect", "--format", "{{.Image}}", *containers] + ) + if result.returncode != 0: + return None + images = result.stdout.split() + if len(images) != len(containers) or any( + not re.fullmatch(r"sha256:[0-9a-f]{64}", item) for item in images + ): + return None + return set(images) + except (OSError, subprocess.TimeoutExpired): + return None def _dangling_images() -> dict | None: - """Return count and approx bytes of dangling () images.""" + """Count unreferenced dangling images; their logical sizes can share layers.""" try: result = _run_cmd( - ["docker", "images", "-f", "dangling=true", "--format", "{{.Size}}"] + [ + "docker", "images", "--no-trunc", "-f", "dangling=true", + "--format", "{{.ID}}\t{{.Size}}", + ] ) except (OSError, subprocess.TimeoutExpired): return None if result.returncode != 0: return None + rows = [line.split("\t") for line in result.stdout.splitlines() if line.strip()] + if any( + len(row) != 2 or not re.fullmatch(r"sha256:[0-9a-f]{64}", row[0]) + for row in rows + ): + return None + used = _container_image_ids() if rows else set() sizes = [ - _size_to_bytes(line) for line in result.stdout.splitlines() if line.strip() + _size_to_bytes(size) for image, size in rows + if used is None or image not in used ] - return {"count": len(sizes), "bytes": sum(sizes)} + return {"count": len(sizes), "bytes": sum(sizes), "usage_known": used is not None} def _docker_buildcache_scan() -> dict: @@ -1238,7 +1277,10 @@ def _docker_buildcache_scan() -> dict: "driver": "docker", "current": False, "total_bytes": dangling["bytes"], - "reclaimable_bytes": dangling["bytes"], + # Shared layers and concurrent container creation prevent a + # logical image-size sum from predicting actual prune results. + "reclaimable_bytes": None, + "usage_known": dangling["usage_known"], "count": dangling["count"], "command": ["docker", "image", "prune", "--force"], } @@ -1284,6 +1326,12 @@ def _cleanup_docker_buildcache( if note else f"reclaimable ~{_format_bytes(reclaimable)}" ) + else: + note = f"{note}, odzyskiwalny rozmiar nieznany".lstrip(", ") + if target["kind"] == "dangling-images": + note += "; suma rozmiarów obrazów obejmuje współdzielone warstwy" + if not target["usage_known"]: + note += "; użycie przez kontenery nieznane" candidates.append( { "description": label, diff --git a/project/ticket-113/README.md b/project/ticket-113/README.md new file mode 100644 index 0000000..7dea03e --- /dev/null +++ b/project/ticket-113/README.md @@ -0,0 +1,34 @@ +# Ticket 113: Report Docker dangling-image reclaimability honestly + +- **ID**: ticket-113 +- **Owner**: agent:codex +- **Status**: IN_PROGRESS +- **Workflow state**: PUBLICATION +- **Created**: 2026-10-31 + +## Goal and scope + +On this host FixOS advertised 22.6 GB of dangling images as reclaimable, but +Docker prune reclaimed 0 B. Three 7 GB images were still referenced by running +containers. Exclude referenced images from the prune inventory and distinguish +logical image sizes from unknown shared-layer reclaimability. A missing +BuildKit reclaimable measurement must remain unknown. + +SESSION_EXECUTION_AUTHORIZATION: the user requested continuing FixOS repairs, +cleanup and protected publication on 2026-10-31. This bounded diagnostic repair +uses that authority; independent approval and merge remain controller-owned. + +## Acceptance criteria + +- [x] AC-01: Images referenced by running or stopped containers are excluded; + failed usage reads and shared-layer totals never claim known reclaimability. +- [x] AC-02: Missing BuildKit reclaimable measurements remain unknown; CLI and + JSON output distinguish unknown amounts; existing safe prune commands remain. +- [ ] AC-03: Local checks pass and exact-head independent publication is requested. + +## Validation + +- Nine regression cases failed before the fix; all 10 now pass. +- Focused cleanup suites: 32 passed. Ruff, import sorting and managed governance passed. +- Live read-only inventory no longer lists images used by containers as prune candidates. +- Full governed checks and exact-head independent publication run through Goal and the protected Validator. diff --git a/project/ticket-113/intent.json b/project/ticket-113/intent.json new file mode 100644 index 0000000..516ffd6 --- /dev/null +++ b/project/ticket-113/intent.json @@ -0,0 +1,79 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-113", + "summary": "Report Docker dangling-image reclaimability honestly", + "workstream": "application", + "classification": { + "kind": "BUG", + "priority": "P2", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-113/**", + "fixos/cli/_cleanup_space.py", + "tests/unit/test_cleanup_docker_buildcache.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "python" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "0afa281482622cf1816293e5a5e9e61b5d043e46", + "targetBranch": "main", + "outcome": "Publish honest Docker buildcache estimates: exclude images referenced by containers and preserve unknown reclaimability for shared image layers or missing BuildKit measurements.", + "nonGoals": [ + "No new cleanup effects or Docker volume removal", + "No cleanup retention policy changes", + "No runtime dependency changes" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 2, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Read full dangling image IDs and container image references; separate aggregate image size from unknown uniquely reclaimable storage.", + "components": [ + { + "name": "docker-cleanup-inventory", + "paths": [ + "fixos/cli/_cleanup_space.py", + "tests/unit/test_cleanup_docker_buildcache.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "Return null for unknown reclaimable bytes instead of logical image totals." + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert through protected publication; existing Docker prune commands are retained." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest tests/unit/test_cleanup_docker_buildcache.py -q", + "python -m ruff check --select I fixos/cli/_cleanup_space.py tests/unit/test_cleanup_docker_buildcache.py", + "./project/governance-check.sh" + ], + "evidence": "project/ticket-113/README.md" + } + ] + } +} diff --git a/tests/unit/test_cleanup_docker_buildcache.py b/tests/unit/test_cleanup_docker_buildcache.py new file mode 100644 index 0000000..86dfdc9 --- /dev/null +++ b/tests/unit/test_cleanup_docker_buildcache.py @@ -0,0 +1,103 @@ +"""Docker cleanup estimates must distinguish logical size from reclaimability.""" + +import subprocess + +import pytest +from click.testing import CliRunner + +from fixos.cli import _cleanup_space as cleanup + +IMAGE_A = "sha256:" + "a" * 64 +IMAGE_B = "sha256:" + "b" * 64 +CONTAINER = "c" * 64 + + +def stub_docker(monkeypatch, *, containers="", inspected="", failure=None): + calls = [] + + def run(argv, timeout=60): + calls.append(argv) + if argv[:2] == ["docker", "images"]: + output = f"{IMAGE_A}\t7GB\n{IMAGE_B}\t7GB\n" + elif argv[:3] == ["docker", "container", "ls"]: + output = containers + elif argv[:3] == ["docker", "container", "inspect"]: + if failure is not None: + if isinstance(failure, Exception): + raise failure + return subprocess.CompletedProcess(argv, failure, "", "unavailable") + output = inspected + else: + raise AssertionError(argv) + return subprocess.CompletedProcess(argv, 0, output, "") + + monkeypatch.setattr(cleanup, "_run_cmd", run) + return calls + + +def test_images_referenced_by_any_container_are_excluded(monkeypatch): + calls = stub_docker(monkeypatch, containers=CONTAINER + "\n", inspected=IMAGE_A + "\n") + result = cleanup._dangling_images() + assert result["count"] == 1 + assert result["bytes"] == 7_000_000_000 + assert result["usage_known"] is True + assert ["docker", "container", "ls", "--all", "--quiet", "--no-trunc"] in calls + assert any("--no-trunc" in argv for argv in calls if argv[1] == "images") + + +@pytest.mark.parametrize("failure", [1, OSError("unavailable"), subprocess.TimeoutExpired("docker", 60)]) +def test_failed_container_inspection_keeps_usage_unknown(monkeypatch, failure): + stub_docker(monkeypatch, containers=CONTAINER + "\n", failure=failure) + result = cleanup._dangling_images() + assert result["usage_known"] is False + assert result["count"] == 2 + + +def test_no_containers_needs_no_inspection(monkeypatch): + calls = stub_docker(monkeypatch) + result = cleanup._dangling_images() + assert result["usage_known"] is True + assert result["count"] == 2 + assert not any("inspect" in argv for argv in calls) + + +def test_image_totals_do_not_claim_shared_layers_as_reclaimable(monkeypatch): + stub_docker(monkeypatch) + monkeypatch.setattr(cleanup, "_docker_buildx_builders", list) + target = cleanup._docker_buildcache_scan()["targets"][0] + assert target["total_bytes"] == 14_000_000_000 + assert target["reclaimable_bytes"] is None + assert target["command"] == ["docker", "image", "prune", "--force"] + + +def test_all_referenced_images_produce_no_prune_target(monkeypatch): + stub_docker(monkeypatch, containers=f"{CONTAINER}\n{'d' * 64}\n", inspected=f"{IMAGE_A}\n{IMAGE_B}\n") + monkeypatch.setattr(cleanup, "_docker_buildx_builders", list) + assert cleanup._docker_buildcache_scan()["targets"] == [] + + +def test_missing_builder_reclaimable_measurement_is_unknown(monkeypatch): + monkeypatch.setattr(cleanup, "_run_cmd", lambda argv, **kw: subprocess.CompletedProcess(argv, 0, "Total: 14GB\n", "")) + assert cleanup._builder_cache_bytes("default") == (14_000_000_000, None) + + +def test_measured_zero_reclaimable_stays_zero(monkeypatch): + monkeypatch.setattr(cleanup, "_run_cmd", lambda argv, **kw: subprocess.CompletedProcess(argv, 0, "Reclaimable: 0B\nTotal: 14GB\n", "")) + assert cleanup._builder_cache_bytes("default") == (14_000_000_000, 0) + + +def test_cli_labels_unknown_reclaimability(monkeypatch): + import click + + stub_docker(monkeypatch) + monkeypatch.setattr(cleanup, "_docker_buildx_builders", list) + + @click.command() + def command(): + cleanup._cleanup_docker_buildcache(False, True, False, False) + + result = CliRunner().invoke(command) + assert result.exit_code == 0, result.output + assert "odzyskiwalny rozmiar nieznany" in result.output + assert "suma rozmiarów obrazów" in result.output + assert "docker image prune --force" in result.output