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
62 changes: 55 additions & 7 deletions fixos/cli/_cleanup_space.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 (<none>) 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:
Expand Down Expand Up @@ -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"],
}
Expand Down Expand Up @@ -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,
Expand Down
34 changes: 34 additions & 0 deletions project/ticket-113/README.md
Original file line number Diff line number Diff line change
@@ -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.
79 changes: 79 additions & 0 deletions project/ticket-113/intent.json
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
}
103 changes: 103 additions & 0 deletions tests/unit/test_cleanup_docker_buildcache.py
Original file line number Diff line number Diff line change
@@ -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
Loading