From 87bfacb49a9fb1afe6bf159ade1880a630dc51dc Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 21 Oct 2026 16:32:55 +0200 Subject: [PATCH 1/3] feat(cleanup): reclaim disk until a free-space goal via --free-gb (ticket-093) --- fixos/cli/_cleanup_free_target.py | 341 +++++++++++++++++++++++++ fixos/cli/cleanup_cmd.py | 190 +++++++++++++- project/ticket-093/README.md | 44 ++++ project/ticket-093/intent.json | 86 +++++++ tests/unit/test_cleanup_free_target.py | 277 ++++++++++++++++++++ 5 files changed, 937 insertions(+), 1 deletion(-) create mode 100644 fixos/cli/_cleanup_free_target.py create mode 100644 project/ticket-093/README.md create mode 100644 project/ticket-093/intent.json create mode 100644 tests/unit/test_cleanup_free_target.py diff --git a/fixos/cli/_cleanup_free_target.py b/fixos/cli/_cleanup_free_target.py new file mode 100644 index 0000000..f81bd07 --- /dev/null +++ b/fixos/cli/_cleanup_free_target.py @@ -0,0 +1,341 @@ +"""Target-driven cleanup ladder: reclaim disk until a free-space goal is met. + +The NoLimits LIM-STORAGE-001 runbook wants "cleanup until N GB free". +``--threshold-gb`` cannot express that (it filters which *services* are +reported), so this module provides the goal-oriented pieces: + +- :func:`free_bytes` — statvfs-based free space of a filesystem. +- :func:`find_stale_release_caches` — agent/release cache directories under + ``~/.local/share/*/releases|release|snapshots`` untouched for a minimum + number of days. Read-only (protected) trees are reported separately so the + caller never tries to delete them. +- :func:`remove_release_caches` — best-effort removal that skips protected + paths and returns the bytes actually reclaimed. +- :func:`docker_call` — batched ``docker`` subprocess with a hard timeout so a + busy daemon skips a tier instead of hanging the whole command. +- :func:`run_free_target_ladder` — generic runner that measures free space + before and after each tier and stops as soon as the goal is reached. + +Nothing here deletes user documents, models, editor data, Docker volumes, or +anything that needs elevated privileges. +""" + +from __future__ import annotations + +import os +import shutil +import subprocess +import time +from collections.abc import Callable, Iterable +from dataclasses import dataclass, field +from pathlib import Path + +DEFAULT_FREE_TARGET_GB = 10.0 +DEFAULT_RELEASE_CACHE_MIN_DAYS = 7.0 +DEFAULT_DOCKER_IMAGE_MIN_DAYS = 14.0 +DOCKER_CALL_TIMEOUT_SECONDS = 30 +_RELEASE_PARENT_NAMES = frozenset({"releases", "release", "snapshots"}) + + +@dataclass +class TierResult: + """Outcome of one ladder tier.""" + + name: str + attempted: bool = True + skipped_reason: str | None = None + bytes_freed: int = 0 + note: str = "" + + +@dataclass +class LadderReport: + """Aggregated ladder outcome.""" + + goal_bytes: int + started_free_bytes: int + ended_free_bytes: int = 0 + tiers: list[TierResult] = field(default_factory=list) + + @property + def goal_reached(self) -> bool: + return self.ended_free_bytes >= self.goal_bytes + + +def free_bytes(path: str | Path = "/") -> int: + """Return free bytes on the filesystem containing ``path`` (0 on error).""" + try: + stat = os.statvfs(str(path)) + except OSError: + return 0 + return stat.f_bavail * stat.f_frsize + + +def _dir_size(path: Path) -> int: + """Best-effort recursive size (0 on error, like du with ignored errors).""" + total = 0 + try: + with os.scandir(path) as entries: + for entry in entries: + try: + if entry.is_symlink(): + continue + if entry.is_dir(follow_symlinks=False): + total += _dir_size(Path(entry.path)) + else: + total += entry.stat(follow_symlinks=False).st_size + except OSError: + continue + return total + except OSError: + return total + + +def find_stale_release_caches( + share_root: Path | str | None = None, + min_days: float = DEFAULT_RELEASE_CACHE_MIN_DAYS, + *, + now: float | None = None, +) -> tuple[list[dict], list[dict]]: + """Return ``(removable, protected)`` stale release-cache entries. + + An entry is a directory directly below + ``//releases|release|snapshots/`` whose own mtime is + older than ``min_days`` days. ```` directories holding the current + working tree of a running tool are indistinguishable by mtime alone, so + callers should rely on the write-protection check: entries inside a + non-writable parent are returned as ``protected`` and never selected for + removal. + """ + root = Path(share_root) if share_root is not None else Path.home() / ".local/share" + now = time.time() if now is None else now + cutoff = now - min_days * 86400.0 + removable: list[dict] = [] + protected: list[dict] = [] + try: + owners = sorted(p for p in root.iterdir() if p.is_dir()) + except OSError: + return removable, protected + for owner in owners: + for parent_name in sorted(_RELEASE_PARENT_NAMES): + parent = owner / parent_name + if not parent.is_dir(): + continue + try: + children = sorted(parent.iterdir()) + except OSError: + continue + for child in children: + try: + stat = child.stat(follow_symlinks=False) + except OSError: + continue + if not os.path.isdir(child) or child.is_symlink(): + continue + if stat.st_mtime > cutoff: + continue + entry = { + "path": child, + "owner": owner.name, + "mtime": stat.st_mtime, + "size_bytes": _dir_size(child), + } + if os.access(parent, os.W_OK) and os.access(child, os.W_OK): + removable.append(entry) + else: + protected.append(entry) + return removable, protected + + +def remove_release_caches(entries: Iterable[dict], *, dry_run: bool = False) -> int: + """Remove stale release-cache entries; return bytes reclaimed.""" + reclaimed = 0 + for entry in entries: + path = Path(entry["path"]) + size = int(entry.get("size_bytes") or 0) + if dry_run: + reclaimed += size + continue + try: + shutil.rmtree(path) + except OSError: + # Newly protected or busy: skip without counting. + continue + reclaimed += size + return reclaimed + + +def docker_call( + args: list[str], timeout: int = DOCKER_CALL_TIMEOUT_SECONDS +) -> tuple[bool, str]: + """Run ``docker `` with a hard timeout. + + Returns ``(ok, output)``. A missing binary, a timeout or a non-zero exit + is a skipped tier, never a crash of the whole ladder. + """ + try: + completed = subprocess.run( + ["docker", *args], + capture_output=True, + text=True, + timeout=timeout, + check=False, + ) + except FileNotFoundError: + return False, "docker not available" + except subprocess.TimeoutExpired: + return False, f"docker {args[0]} timed out after {timeout}s (busy daemon)" + output = (completed.stdout or "") + (completed.stderr or "") + return completed.returncode == 0, output.strip() + + +def _docker_used_image_refs(timeout: int) -> tuple[set[str], set[str]] | None: + """Return ``(used_ids, used_names)`` from one cheap ``ps -a`` call. + + Container→image resolution via ``docker inspect`` races with the daemon + on busy hosts (containers vanish mid-chunk); ``ps --format {{.Image}}`` + already carries the reference (``repo:tag`` or ``sha256:…``). + """ + ok, out = docker_call(["ps", "-a", "--format", "{{.Image}}"], timeout) + if not ok: + return None + used_ids: set[str] = set() + used_names: set[str] = set() + for line in out.splitlines(): + ref = line.strip() + if not ref: + continue + if ref.startswith("sha256:"): + used_ids.add(ref.split(":", 1)[1]) + else: + used_names.add(ref) + return used_ids, used_names + + +def docker_prune_dangling(timeout: int = DOCKER_CALL_TIMEOUT_SECONDS) -> TierResult: + """Prune dangling images and old build cache (no volumes, no tagged data).""" + ok, out = docker_call(["image", "prune", "-f"], timeout) + if not ok: + return TierResult("docker-dangling", attempted=False, skipped_reason=out) + ok_cache, out_cache = docker_call( + ["builder", "prune", "-f", "--filter", "until=168h"], timeout + ) + note = out_cache if not ok_cache else "" + return TierResult("docker-dangling", note=note) + + +def _parse_docker_created(value: str) -> float: + for fmt in ("%Y-%m-%d %H:%M:%S %z", "%Y-%m-%d %H:%M:%S"): + try: + return time.mktime(time.strptime(value.strip(), fmt)) + except ValueError: + continue + return 0.0 + + +def docker_remove_unused_old_images( + min_days: float = DEFAULT_DOCKER_IMAGE_MIN_DAYS, + timeout: int = DOCKER_CALL_TIMEOUT_SECONDS, +) -> TierResult: + """Remove images referenced by no container and older than ``min_days``.""" + used = _docker_used_image_refs(timeout) + if used is None: + return TierResult( + "docker-unused-images", attempted=False, skipped_reason="docker unavailable" + ) + used_ids, used_names = used + ok, out = docker_call( + ["images", "--format", "{{.ID}}\t{{.Repository}}:{{.Tag}}\t{{.CreatedAt}}"], + timeout, + ) + if not ok: + return TierResult("docker-unused-images", attempted=False, skipped_reason=out) + cutoff = time.time() - min_days * 86400.0 + candidates: list[str] = [] + for line in out.splitlines(): + parts = [p.strip() for p in line.split("\t")] + if len(parts) != 3: + continue + image_id, name, created = parts + if image_id in used_ids or name in used_names or name.startswith(":"): + continue + if ( + _parse_docker_created(created) == 0.0 + or _parse_docker_created(created) > cutoff + ): + continue + candidates.append(image_id) + if not candidates: + return TierResult("docker-unused-images") + # Batched rmi: conflicting (multi-repo) images are skipped by the daemon. + ok, out = docker_call(["rmi", *candidates], timeout) + if not ok: + return TierResult( + "docker-unused-images", attempted=False, skipped_reason=out[:200] + ) + return TierResult("docker-unused-images") + + +def run_free_target_ladder( + goal_gb: float, + *, + tiers: list[Callable[[], TierResult]], + free_fn: Callable[[], int] = lambda: free_bytes("/"), + echo: Callable[[str], None] = print, + dry_run: bool = False, +) -> LadderReport: + """Run tiers in order, stopping once free space reaches ``goal_gb``. + + Tiers are callables returning :class:`TierResult`; the free-space delta + measured around each tier is authoritative (tier-reported byte counts are + advisory only). + """ + report = LadderReport( + goal_bytes=int(goal_gb * 1024**3), + started_free_bytes=free_fn(), + ) + report.ended_free_bytes = report.started_free_bytes + if report.started_free_bytes >= report.goal_bytes: + echo( + f"Cel już osiągnięty: {report.started_free_bytes / 1024**3:.1f} GB wolnego " + f"(cel {goal_gb:.1f} GB)." + ) + return report + for tier in tiers: + before = free_fn() + result = tier() + after = free_fn() + result.bytes_freed = max(0, after - before) + report.tiers.append(result) + report.ended_free_bytes = after + if result.attempted: + echo( + f" • {result.name}: zwolniono {result.bytes_freed / 1024**3:.2f} GB" + + (f" ({result.skipped_reason})" if result.skipped_reason else "") + ) + else: + echo(f" • {result.name}: pominięto ({result.skipped_reason})") + if dry_run: + continue + if after >= report.goal_bytes: + break + return report + + +def print_privileged_hints(goal_gb: float, report: LadderReport) -> None: + """Print exact next steps needing privileges when the goal is unmet.""" + if report.goal_reached: + return + gap_gb = (report.goal_bytes - report.ended_free_bytes) / 1024**3 + print() + print( + f"Brakuje ~{gap_gb:.1f} GB do celu {goal_gb:.1f} GB. " + "Kolejne pule wymagają uprawnień — wykonaj ręcznie:" + ) + print(" sudo snap set system refresh.retain=2 # ogranicz przyszłe rewizje") + print( + " snap list --all | awk '/disabled/{print $1, $3}' | " + 'while read n r; do sudo snap remove "$n" --revision="$r"; done' + ) + print(" sudo journalctl --vacuum-size=500M # przycięcie dziennika") + print(" sudo rm /var/log/syslog.[0-9].gz # stare rotacje sysloga") diff --git a/fixos/cli/cleanup_cmd.py b/fixos/cli/cleanup_cmd.py index c8f51c1..23ee946 100644 --- a/fixos/cli/cleanup_cmd.py +++ b/fixos/cli/cleanup_cmd.py @@ -18,6 +18,15 @@ # Re-export public symbols used by fixos.cli (backward-compat) from fixos.cli._cleanup_flatpak import _cleanup_flatpak_detailed +from fixos.cli._cleanup_free_target import ( + TierResult, + docker_prune_dangling, + docker_remove_unused_old_images, + find_stale_release_caches, + print_privileged_hints, + remove_release_caches, + run_free_target_ladder, +) from fixos.cli._cleanup_space import ( _cleanup_docker_buildcache, _cleanup_gitive, @@ -1580,6 +1589,128 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None: _display_dangerous_services(plan["dangerous"]) +def _run_free_target_mode( + goal_gb: float, + threshold_mb: int, + json_output: bool, + dry_run: bool, + days: float | None, +) -> None: + """Reclaim disk space in a bounded ladder until ``goal_gb`` is free.""" + from fixos.cli._cleanup_free_target import DEFAULT_DOCKER_IMAGE_MIN_DAYS + + def _echo(message: str) -> None: + if not json_output: + click.echo(message) + + def _safe_service_caches() -> TierResult: + scanner = ServiceDataScanner(threshold_mb=threshold_mb) + plan = scanner.get_cleanup_plan(selected_services=None) + _execute_yes_cleanup(plan, scanner, dry_run=dry_run) + return TierResult("safe-service-caches") + + def _stale_tmp() -> TierResult: + _cleanup_tmp( + json_output, + dry_run, + list_only=False, + yes=True, + days=3, + ) + return TierResult("tmp-stale-3d") + + def _stale_release_caches() -> TierResult: + removable, protected = find_stale_release_caches() + _echo( + f" release caches: {len(removable)} do usunięcia, " + f"{len(protected)} chronionych (tylko do odczytu)" + ) + remove_release_caches(removable, dry_run=dry_run) + return TierResult("stale-release-caches") + + min_days = days if days is not None else DEFAULT_DOCKER_IMAGE_MIN_DAYS + + def _docker_tier(action, name: str): + def _tier() -> TierResult: + if dry_run: + return TierResult( + name, + attempted=False, + skipped_reason="dry-run pomija efekty Dockera", + ) + return action() + + return _tier + + tiers = [ + _safe_service_caches, + _stale_tmp, + _stale_release_caches, + _docker_tier(docker_prune_dangling, "docker-dangling"), + _docker_tier( + lambda: docker_remove_unused_old_images(min_days=min_days), + "docker-unused-images", + ), + ] + _echo( + click.style( + f"Cel: {goal_gb:.1f} GB wolnego miejsca — schodzę po bezpiecznych szczeblach.", + fg="cyan", + bold=True, + ) + ) + report = run_free_target_ladder( + goal_gb, + tiers=tiers, + echo=_echo, + dry_run=dry_run, + ) + freed = (report.ended_free_bytes - report.started_free_bytes) / 1024**3 + _echo("") + if report.goal_reached: + _echo( + click.style( + f"Cel osiągnięty: {report.ended_free_bytes / 1024**3:.1f} GB wolnego " + f"(zwolniono {freed:.2f} GB).", + fg="green", + bold=True, + ) + ) + else: + _echo( + click.style( + f"Zwolniono {freed:.2f} GB, wolne: " + f"{report.ended_free_bytes / 1024**3:.1f} GB — cel nieosiągnięty.", + fg="yellow", + bold=True, + ) + ) + print_privileged_hints(goal_gb, report) + if json_output: + import json + + click.echo( + json.dumps( + { + "goal_gb": goal_gb, + "goal_reached": report.goal_reached, + "started_free_gb": round(report.started_free_bytes / 1024**3, 2), + "ended_free_gb": round(report.ended_free_bytes / 1024**3, 2), + "tiers": [ + { + "name": tier.name, + "attempted": tier.attempted, + "skipped_reason": tier.skipped_reason, + "bytes_freed": tier.bytes_freed, + } + for tier in report.tiers + ], + }, + indent=2, + ) + ) + + # ── Main CLI command ────────────────────────────────────────────────────── @@ -1596,7 +1727,21 @@ def _execute_yes_cleanup(plan: dict, scanner, dry_run: bool = False) -> None: "threshold_gb", default=None, type=float, - help="Próg wielkości w GB (nadpisuje --threshold)", + help=( + "Próg wielkości w GB (nadpisuje --threshold). To NIE jest docelowa " + "wolna przestrzeń — do celu czyszczenia użyj --free-gb" + ), +) +@click.option( + "--free-gb", + "--target-free-gb", + "free_gb", + default=None, + type=float, + help=( + "Czyść schodząc po bezpiecznych szczeblach aż do osiągnięcia " + "N GB wolnego miejsca (domyślnie 10). Uruchamiaj jako osobną akcję" + ), ) @click.option( "--services", @@ -1947,6 +2092,7 @@ def cleanup_services( dry_run, list_only, yes, + free_gb, full_analysis, ) -> None: """ @@ -1965,6 +2111,8 @@ def cleanup_services( fixos cleanup -s docker,ollama # tylko Docker i Ollama fixos cleanup --list # tylko lista, bez czyszczenia fixos cleanup --yes # bez pytań: wszystkie bezpieczne + fixos cleanup --free-gb 10 # czyść schodami aż do 10 GB wolnego + fixos cleanup --free-gb 10 --dry-run # tylko symulacja drabiny fixos cleanup -c docker --dry-run # tylko cache buildów >7 dni fixos cleanup --docker-all --dry-run # wszystkie unused images/cache + osierocone sieci @@ -2067,6 +2215,46 @@ def cleanup_services( return if projects_path is not None: raise click.UsageError("--projects-path wymaga --venvs-old") + if free_gb is not None: + if any( + ( + full_analysis, + bool(services), + bool(cleanup), + docker_old, + docker_all, + docker_buildcache, + docker_networks, + docker_stale_services, + orphaned_projects, + pin_orphan_project is not None, + unpin_orphan_project is not None, + list_orphan_pins, + ollama_old, + snap_old, + docker_volumes, + journal_cleanup, + user_cache, + jetbrains, + libvirt, + gitive, + tmp_cleanup, + trash_cleanup, + thumbnails_cleanup, + state_old, + venvs_old, + policy_matrix, + ) + ): + raise click.UsageError("--free-gb uruchom jako osobną akcję") + _run_free_target_mode( + goal_gb=free_gb, + threshold_mb=threshold, + json_output=json_output, + dry_run=dry_run, + days=effective_days, + ) + return pin_actions = sum( value is not None and value is not False for value in (pin_orphan_project, unpin_orphan_project, list_orphan_pins) diff --git a/project/ticket-093/README.md b/project/ticket-093/README.md new file mode 100644 index 0000000..f47b006 --- /dev/null +++ b/project/ticket-093/README.md @@ -0,0 +1,44 @@ +# Ticket 093: Target-driven cleanup that reclaims disk until a free-space goal is met + +- **ID**: ticket-093 +- **Owner**: agent:opencode.glm-5.3 +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-10-21 + +## Goal and scope + +`fixos cleanup --threshold-gb 10` (the invocation suggested by the NoLimits +LIM-STORAGE-001 runbook) sets a *per-service scan threshold*, not a free-space +goal: with it, only services larger than 10 GB are even reported, and on this +host those are exactly the protected ones (Ollama models, snap). `--yes` then +cleans just the small safe caches and stops far below a safe margin while the +real reclaimable pools are never touched. + +This ticket adds a target-driven mode: + +- `fixos cleanup --free-gb N` (alias `--target-free-gb`) — reclaim until + `statvfs('/')` free space reaches N GB, using a bounded ladder of + no-privilege tiers, stopping at the goal: + 1. safe service caches (existing `--yes` plan), + 2. stale `/tmp` entries (existing `_cleanup_tmp`, default 3 days), + 3. stale agent release caches `~/.local/share/*/releases|release|snapshots/*` + untouched for 7+ days (new; read-only/protected directories are skipped), + 4. Docker dangling images + build cache prune (new; hard 30 s per-call + timeout so a busy daemon skips the tier instead of hanging), + 5. Docker images unreferenced by any container and older than `--days` + (default 14) (new, same timeout guard). +- When the goal is still unmet, print the exact privileged next steps (old + snap revisions, rotated syslog archives) instead of failing silently. +- `--threshold-gb` keeps its per-service meaning; help text now states it is + not a free-space goal. + +## Acceptance criteria + +- [ ] AC-01: `python -m pytest tests/unit/test_cleanup_free_target.py tests/unit/test_cleanup_cmd.py tests/unit/test_cleanup_tmp.py -q` passes. +- [ ] AC-02: `./project/governance-check.sh` passes. + +## Tracking boundary + +This directory contains the minimal reviewed intent. Optional participant +prose and raw command logs are not required delivery output. diff --git a/project/ticket-093/intent.json b/project/ticket-093/intent.json new file mode 100644 index 0000000..2a4e4e1 --- /dev/null +++ b/project/ticket-093/intent.json @@ -0,0 +1,86 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-093", + "summary": "Target-driven cleanup that reclaims disk until a free-space goal is met", + "workstream": "application", + "classification": { + "kind": "FEATURE", + "priority": "P1", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-093/**", + "TODO.md", + "project/TICKETS.md", + "fixos/cli/_cleanup_free_target.py", + "fixos/cli/cleanup_cmd.py", + "tests/unit/test_cleanup_free_target.py" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "e13966afc627ea3aa71197d72fb82d94d39864d0", + "targetBranch": "main", + "outcome": "fixos cleanup --free-gb N (alias --target-free-gb) reclaims disk space in a bounded ladder of no-privilege tiers - safe service caches, stale /tmp entries, stale agent release caches under ~/.local/share/*/releases older than 7 days, Docker dangling images and build cache, and Docker images unreferenced by any container and older than --days - stopping as soon as statvfs free space reaches the goal. Every Docker call runs with a hard timeout so a busy daemon skips its tier instead of hanging, non-writable (protected) release directories are skipped, and when the goal is still unmet the command prints the exact privileged next steps (old snap revisions, rotated syslog) instead of failing silently. --threshold-gb keeps its per-service scan meaning and its help text now says it is not a free-space goal.", + "nonGoals": [ + "No deletion of user documents, Ollama models, editor extensions, Docker volumes or any path requiring elevated privileges", + "No change to existing sub-action flags or their semantics", + "No scheduler/daemon: the ladder runs only when explicitly requested" + ], + "complexity": "M", + "estimatedMinutes": 60, + "budgets": { + "maxImplementationFiles": 3, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 1, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Add fixos/cli/_cleanup_free_target.py with pure helpers (statvfs free bytes, stale release-cache discovery by mtime with protected-path skipping, batched docker subprocess calls with timeouts) and a generic ladder runner that measures free space before and after each tier and stops at the goal. cleanup_cmd.py gains --free-gb/--target-free-gb which must run as a standalone action (same guard pattern as --venvs-old) and composes existing handlers (safe plan via --yes executor, _cleanup_tmp for stale temp) with the new tiers. Unit tests mock df and docker so the ladder logic is verified without touching a real system.", + "components": [ + { + "name": "cleanup-free-target", + "paths": [ + "fixos/cli/_cleanup_free_target.py", + "fixos/cli/cleanup_cmd.py", + "tests/unit/test_cleanup_free_target.py" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [ + "New cleanup options --free-gb/--target-free-gb (standalone action, implies the safe --yes pass first)" + ], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the commit; cleanup keeps only the pre-existing per-service and sub-action modes." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "python -m pytest tests/unit/test_cleanup_free_target.py tests/unit/test_cleanup_cmd.py tests/unit/test_cleanup_tmp.py -q" + ], + "evidence": "project/ticket-093/README.md" + }, + { + "criterion": "AC-02", + "commands": [ + "./project/governance-check.sh" + ], + "evidence": "project/ticket-093/README.md" + } + ] + } +} diff --git a/tests/unit/test_cleanup_free_target.py b/tests/unit/test_cleanup_free_target.py new file mode 100644 index 0000000..357284c --- /dev/null +++ b/tests/unit/test_cleanup_free_target.py @@ -0,0 +1,277 @@ +"""Unit tests for the target-driven cleanup ladder (ticket-093).""" + +from __future__ import annotations + +import os +import subprocess +import time +from pathlib import Path + +from click.testing import CliRunner + +from fixos.cli import cleanup_cmd +from fixos.cli._cleanup_free_target import ( + TierResult, + docker_call, + find_stale_release_caches, + free_bytes, + remove_release_caches, + run_free_target_ladder, +) + +# ── free_bytes ──────────────────────────────────────────────────────────── + + +def test_free_bytes_matches_statvfs(): + stat = os.statvfs("/") + assert free_bytes("/") == stat.f_bavail * stat.f_frsize + assert free_bytes("/nonexistent-path-xyz") == 0 + + +# ── run_free_target_ladder ──────────────────────────────────────────────── + + +def test_ladder_stops_as_soon_as_goal_reached(): + # Convention: one free_fn() call at start plus one before and one after + # each tier. + free_values = iter( + [2 * 1024**3, 4 * 1024**3, 8 * 1024**3, 8 * 1024**3, 12 * 1024**3] + ) + calls = [] + + def free_fn(): + return next(free_values) + + def tier(name, freed_marker): + def _tier(): + calls.append(freed_marker) + return TierResult(name) + + return _tier + + report = run_free_target_ladder( + 10.0, + tiers=[tier("a", 1), tier("b", 2), tier("c", 3)], + free_fn=free_fn, + echo=lambda _msg: None, + ) + assert calls == [1, 2] # third tier never runs + assert report.goal_reached + assert [t.name for t in report.tiers] == ["a", "b"] + assert report.tiers[-1].bytes_freed == 4 * 1024**3 + + +def test_ladder_runs_all_tiers_when_goal_unmet(): + free_values = iter([1024, 2048, 3072, 4096, 5120]) + + def free_fn(): + return next(free_values) + + report = run_free_target_ladder( + 10.0, + tiers=[lambda: TierResult("a"), lambda: TierResult("b")], + free_fn=free_fn, + echo=lambda _msg: None, + ) + assert not report.goal_reached + assert len(report.tiers) == 2 + + +def test_ladder_skips_tiers_already_at_goal(): + def free_fn(): + return 20 * 1024**3 + + ran = [] + + def _tier(): + ran.append(1) + return TierResult("a") + + report = run_free_target_ladder( + 10.0, tiers=[_tier], free_fn=free_fn, echo=lambda _msg: None + ) + assert not ran + assert report.goal_reached + + +def test_ladder_dry_run_measures_all_tiers(): + free_values = iter([1024, 5 * 1024**3, 9 * 1024**3]) + + def free_fn(): + return next(free_values) + + ran = [] + + def _tier(): + ran.append(1) + return TierResult("a") + + run_free_target_ladder( + 8.0, + tiers=[_tier], + free_fn=free_fn, + echo=lambda _msg: None, + dry_run=True, + ) + # dry-run never breaks early on measured deltas + assert ran == [1] + + +# ── stale release caches ────────────────────────────────────────────────── + + +def _make_release_tree( + root: Path, owner: str, name: str, age_days: float, payload=b"x" * 4096 +): + entry = root / owner / "releases" / name + entry.mkdir(parents=True) + (entry / "payload.bin").write_bytes(payload) + stamp = time.time() - age_days * 86400 + os.utime(entry, (stamp, stamp)) + return entry + + +def test_find_stale_release_caches_selects_only_old_entries(tmp_path): + old = _make_release_tree(tmp_path, "agent-a", "rel-old", age_days=10) + _make_release_tree(tmp_path, "agent-a", "rel-new", age_days=1) + removable, protected = find_stale_release_caches(share_root=tmp_path, min_days=7.0) + assert [e["path"] for e in removable] == [old] + assert protected == [] + + +def test_find_stale_release_caches_reports_readonly_as_protected(tmp_path): + old = _make_release_tree(tmp_path, "agent-b", "rel-locked", age_days=30) + parent = old.parent + os.chmod(parent, 0o555) + try: + removable, protected = find_stale_release_caches( + share_root=tmp_path, min_days=7.0 + ) + assert removable == [] + assert [e["path"] for e in protected] == [old] + finally: + os.chmod(parent, 0o755) + + +def test_remove_release_caches_skips_failures_and_returns_bytes(tmp_path): + kept = _make_release_tree(tmp_path, "agent-c", "rel-busy", age_days=30) + gone = _make_release_tree(tmp_path, "agent-c", "rel-gone", age_days=30) + os.chmod(kept, 0o555) + try: + reclaimed = remove_release_caches( + [ + {"path": kept, "size_bytes": 123}, + {"path": gone, "size_bytes": 456}, + ] + ) + assert reclaimed == 456 + assert kept.exists() + assert not gone.exists() + finally: + os.chmod(kept, 0o755) + + +def test_remove_release_caches_dry_run_keeps_everything(tmp_path): + entry = _make_release_tree(tmp_path, "agent-d", "rel-x", age_days=30) + reclaimed = remove_release_caches( + [{"path": entry, "size_bytes": 100}], dry_run=True + ) + assert reclaimed == 100 + assert entry.exists() + + +# ── docker robustness ───────────────────────────────────────────────────── + + +def test_docker_call_times_out_instead_of_hanging(monkeypatch): + def fake_run(*_args, **_kwargs): + raise subprocess.TimeoutExpired(cmd="docker", timeout=1) + + monkeypatch.setattr(subprocess, "run", fake_run) + ok, reason = docker_call(["system", "df"], timeout=1) + assert not ok + assert "timed out" in reason + + +def test_docker_call_reports_missing_binary(monkeypatch): + def fake_run(*_args, **_kwargs): + raise FileNotFoundError("docker") + + monkeypatch.setattr(subprocess, "run", fake_run) + ok, reason = docker_call(["ps"]) + assert not ok + assert "not available" in reason + + +def test_docker_used_refs_parses_names_and_digests(monkeypatch): + import fixos.cli._cleanup_free_target as target + + def fake_docker_call(args, timeout=30): + assert args[:3] == ["ps", "-a", "--format"] + return True, "sha256:abc123\npostgres:16-alpine\nsha256:abc123\n\n" + + monkeypatch.setattr(target, "docker_call", fake_docker_call) + used = target._docker_used_image_refs(30) + assert used == ({"abc123"}, {"postgres:16-alpine"}) + + +# ── CLI wiring ──────────────────────────────────────────────────────────── + + +def test_free_gb_runs_ladder_and_reports_goal(monkeypatch, tmp_path): + captured = {} + + def fake_mode(goal_gb, threshold_mb, json_output, dry_run, days): + captured.update( + goal_gb=goal_gb, + threshold_mb=threshold_mb, + dry_run=dry_run, + days=days, + ) + + monkeypatch.setattr(cleanup_cmd, "_run_free_target_mode", fake_mode) + runner = CliRunner() + result = runner.invoke( + cleanup_cmd.cleanup_services, + ["--free-gb", "12", "--dry-run"], + ) + assert result.exit_code == 0, result.output + assert captured["goal_gb"] == 12.0 + assert captured["dry_run"] is True + + +def test_free_gb_rejects_other_actions(): + runner = CliRunner() + result = runner.invoke( + cleanup_cmd.cleanup_services, + ["--free-gb", "10", "--docker-old"], + ) + assert result.exit_code != 0 + assert "osobną akcję" in result.output + + +def test_threshold_gb_keeps_per_service_semantics(monkeypatch): + # --threshold-gb must still only override the scan threshold (regression + # guard for the trap fixed in ticket-093). + seen = {} + + class FakeScanner: + def __init__(self, threshold_mb): + seen["threshold_mb"] = threshold_mb + + def get_cleanup_plan(self, selected_services=None): + return { + "services": [], + "services_found": 0, + "safe_to_cleanup": [], + "requires_review": [], + "dangerous": [], + } + + monkeypatch.setattr(cleanup_cmd, "ServiceDataScanner", FakeScanner) + runner = CliRunner() + result = runner.invoke( + cleanup_cmd.cleanup_services, ["--threshold-gb", "2", "--list"] + ) + assert result.exit_code == 0, result.output + assert seen["threshold_mb"] == 2048 From f8e6be31b9e1d67bdd251a467ae7c208903b8458 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 21 Oct 2026 16:46:55 +0200 Subject: [PATCH 2/3] fix(cleanup): root-proof protected release-cache detection (ticket-093) --- fixos/cli/_cleanup_free_target.py | 27 ++++++++++++++++++++------ tests/unit/test_cleanup_free_target.py | 17 ++++++++++++++++ 2 files changed, 38 insertions(+), 6 deletions(-) diff --git a/fixos/cli/_cleanup_free_target.py b/fixos/cli/_cleanup_free_target.py index f81bd07..3088bc5 100644 --- a/fixos/cli/_cleanup_free_target.py +++ b/fixos/cli/_cleanup_free_target.py @@ -91,6 +91,22 @@ def _dir_size(path: Path) -> int: return total +def _write_allowed(path: Path) -> bool: + """True when a deployment has not cleared the owner-write bit. + + ``os.access(W_OK)`` alone is useless under root (root can write + everywhere), and exactly the protected deployment trees are marked by + clearing the write bits. + """ + try: + stat = path.stat(follow_symlinks=False) + except OSError: + return False + if not stat.st_mode & 0o200: + return False + return os.access(path, os.W_OK) + + def find_stale_release_caches( share_root: Path | str | None = None, min_days: float = DEFAULT_RELEASE_CACHE_MIN_DAYS, @@ -101,11 +117,10 @@ def find_stale_release_caches( An entry is a directory directly below ``//releases|release|snapshots/`` whose own mtime is - older than ``min_days`` days. ```` directories holding the current - working tree of a running tool are indistinguishable by mtime alone, so - callers should rely on the write-protection check: entries inside a - non-writable parent are returned as ``protected`` and never selected for - removal. + older than ``min_days`` days. Protection is detected from the write bits: + entries inside a read-only (deployment-protected) parent are returned as + ``protected`` and never selected for removal — also when running as root, + where ``os.access`` alone would claim write access everywhere. """ root = Path(share_root) if share_root is not None else Path.home() / ".local/share" now = time.time() if now is None else now @@ -140,7 +155,7 @@ def find_stale_release_caches( "mtime": stat.st_mtime, "size_bytes": _dir_size(child), } - if os.access(parent, os.W_OK) and os.access(child, os.W_OK): + if _write_allowed(parent) and _write_allowed(child): removable.append(entry) else: protected.append(entry) diff --git a/tests/unit/test_cleanup_free_target.py b/tests/unit/test_cleanup_free_target.py index 357284c..5a93814 100644 --- a/tests/unit/test_cleanup_free_target.py +++ b/tests/unit/test_cleanup_free_target.py @@ -153,6 +153,23 @@ def test_find_stale_release_caches_reports_readonly_as_protected(tmp_path): os.chmod(parent, 0o755) +def test_readonly_protection_also_holds_under_root(tmp_path, monkeypatch): + # CI distro jobs run as root, where os.access(W_OK) is always True; + # the write-bit check must still classify the tree as protected. + old = _make_release_tree(tmp_path, "agent-e", "rel-root", age_days=30) + parent = old.parent + os.chmod(parent, 0o555) + monkeypatch.setattr(os, "access", lambda *_a, **_k: True) + try: + removable, protected = find_stale_release_caches( + share_root=tmp_path, min_days=7.0 + ) + assert removable == [] + assert [e["path"] for e in protected] == [old] + finally: + os.chmod(parent, 0o755) + + def test_remove_release_caches_skips_failures_and_returns_bytes(tmp_path): kept = _make_release_tree(tmp_path, "agent-c", "rel-busy", age_days=30) gone = _make_release_tree(tmp_path, "agent-c", "rel-gone", age_days=30) From 1dce59765cac6a716211af95945ffbe9f395d35e Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Wed, 21 Oct 2026 16:55:41 +0200 Subject: [PATCH 3/3] fix(cleanup): refuse read-only release caches under root executor (ticket-093) --- fixos/cli/_cleanup_free_target.py | 9 +++++- tests/unit/test_cleanup_free_target.py | 43 +++++++++++++++++++------- 2 files changed, 39 insertions(+), 13 deletions(-) diff --git a/fixos/cli/_cleanup_free_target.py b/fixos/cli/_cleanup_free_target.py index 3088bc5..808849f 100644 --- a/fixos/cli/_cleanup_free_target.py +++ b/fixos/cli/_cleanup_free_target.py @@ -163,11 +163,18 @@ def find_stale_release_caches( def remove_release_caches(entries: Iterable[dict], *, dry_run: bool = False) -> int: - """Remove stale release-cache entries; return bytes reclaimed.""" + """Remove stale release-cache entries; return bytes reclaimed. + + Defense in depth: even if a protected entry slips into ``entries``, a + missing owner-write bit on the entry or its parent aborts that removal — + also under root, where permission bits would not stop ``rmtree``. + """ reclaimed = 0 for entry in entries: path = Path(entry["path"]) size = int(entry.get("size_bytes") or 0) + if not _write_allowed(path) or not _write_allowed(path.parent): + continue if dry_run: reclaimed += size continue diff --git a/tests/unit/test_cleanup_free_target.py b/tests/unit/test_cleanup_free_target.py index 5a93814..0c569d9 100644 --- a/tests/unit/test_cleanup_free_target.py +++ b/tests/unit/test_cleanup_free_target.py @@ -170,22 +170,41 @@ def test_readonly_protection_also_holds_under_root(tmp_path, monkeypatch): os.chmod(parent, 0o755) -def test_remove_release_caches_skips_failures_and_returns_bytes(tmp_path): +def test_remove_release_caches_skips_failures_and_returns_bytes(tmp_path, monkeypatch): kept = _make_release_tree(tmp_path, "agent-c", "rel-busy", age_days=30) gone = _make_release_tree(tmp_path, "agent-c", "rel-gone", age_days=30) - os.chmod(kept, 0o555) + + import shutil as real_shutil + + original_rmtree = real_shutil.rmtree + + def fake_rmtree(path, **_kwargs): + if Path(path) == kept: + raise OSError("device busy") + original_rmtree(path, **_kwargs) + + monkeypatch.setattr("fixos.cli._cleanup_free_target.shutil.rmtree", fake_rmtree) + reclaimed = remove_release_caches( + [ + {"path": kept, "size_bytes": 123}, + {"path": gone, "size_bytes": 456}, + ] + ) + assert reclaimed == 456 + assert kept.exists() + assert not gone.exists() + + +def test_remove_release_caches_refuses_readonly_even_under_root(tmp_path, monkeypatch): + entry = _make_release_tree(tmp_path, "agent-f", "rel-locked", age_days=30) + os.chmod(entry, 0o555) + monkeypatch.setattr(os, "access", lambda *_a, **_k: True) try: - reclaimed = remove_release_caches( - [ - {"path": kept, "size_bytes": 123}, - {"path": gone, "size_bytes": 456}, - ] - ) - assert reclaimed == 456 - assert kept.exists() - assert not gone.exists() + reclaimed = remove_release_caches([{"path": entry, "size_bytes": 100}]) + assert reclaimed == 0 + assert entry.exists() finally: - os.chmod(kept, 0o755) + os.chmod(entry, 0o755) def test_remove_release_caches_dry_run_keeps_everything(tmp_path):