diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index bbf57e17..4ee6f729 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -63,6 +63,12 @@ jobs: - name: Check version consistency run: python scripts/bump_version.py --check + - name: Disk-space guard (C:-critical 2026-09-29) + # Fails if the runner disk has <1GB free — full disks cause + # silent truncations in lancedb/onnx caches. Local equivalent: + # python scripts/disk_guard.py --check + run: python scripts/disk_guard.py --check --min-free-gb 1 + - name: Audit dependencies (pip-audit) # Pinned: pip-audit 2.10.1 проверен 2026-08-08 (PYSEC-2026-3552). # Бамп — только после локального прогона `pip-audit -r requirements-lock.txt --no-deps --disable-pip`. diff --git a/scripts/disk_guard.py b/scripts/disk_guard.py new file mode 100644 index 00000000..ac47c2d5 --- /dev/null +++ b/scripts/disk_guard.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Disk guard — self-cleaning mechanism for regenerable trash. + +Covers the 2026-09-29 C:-critical incident (182MB free): TEMP/opencode +experiment DBs + logs, __pycache__, and stale experiment work dirs grow +unbounded without any TTL. This script enforces caps/TTL and fails CI +when the system drive is nearly full. + +Usage: + python scripts/disk_guard.py --check [--min-free-gb 1] + python scripts/disk_guard.py --clean [--dry-run] [--temp-ttl-days 7] + [--work-ttl-days 30] [--repo ROOT] + python scripts/disk_guard.py --report # top hogs, never deletes + +Exit codes: 0 = ok/cleaned, 1 = low disk (--check) or errors, 2 = bad args. + +NEVER deletes (only reports): *.gguf models, venvs, lancedb tables, +.git, source files, committed experiment artifacts (results/, frozen/). +Deletion allowlist: backup_*, tool-output*, *.log[.N], *.err*, pytest_*.txt +in TEMP/opencode; *.log rotation in repo; experiments/**/work TTL. +""" + +from __future__ import annotations + +import argparse +import fnmatch +import os +import shutil +import sys +import tempfile +import time +from dataclasses import dataclass, field +from pathlib import Path + +if sys.stdout.encoding != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + +PROJECT_ROOT = Path(__file__).resolve().parent.parent + +# Deletion allowlist inside TEMP/opencode (name patterns only). +TEMP_TRASH_PATTERNS = ( + "backup_*", + "tool-output*", + "pytest-*", # stale `pytest-of-user` / `pytest-*/` xdist worker dirs (2026-09-29: 847MB) + "*.log", + "*.log.*", + "*.err*", + "pytest_*.txt", +) + +# Big unknown files are reported, never auto-deleted. +REPORT_ONLY_ABOVE_MB = 100 + +LOG_ROTATE_ABOVE_MB = 5 # repo *.log larger than this -> keep one .1 backup +WORK_DIRNAME = "work" # experiments/**/work +PROTECTED_DIR_HINTS = ("frozen", "results") # never touch under these + + +@dataclass +class SweepStats: + deleted_files: int = 0 + deleted_bytes: int = 0 + rotated_logs: int = 0 + errors: list[str] = field(default_factory=list) + reported_big: list[str] = field(default_factory=list) + + +def _older_than(path: Path, days: float, now: float) -> bool: + try: + return (now - path.stat().st_mtime) > days * 86400 + except OSError: + return False + + +def _rmtree_or_file(path: Path, stats: SweepStats, dry_run: bool) -> None: + try: + if dry_run: + size = ( + sum(p.stat().st_size for p in path.rglob("*") if p.is_file()) + if path.is_dir() + else path.stat().st_size + ) + stats.deleted_bytes += size + stats.deleted_files += 1 + return + if path.is_dir() and not path.is_symlink(): + stats.deleted_bytes += sum( + p.stat().st_size for p in path.rglob("*") if p.is_file() + ) + shutil.rmtree(path) + else: + stats.deleted_bytes += path.stat().st_size + path.unlink() + stats.deleted_files += 1 + except OSError as exc: + stats.errors.append(f"{path}: {exc}") + + +def sweep_temp_opencode( + temp_root: Path, ttl_days: float, stats: SweepStats, dry_run: bool +) -> None: + """Delete allowlisted trash older than ttl; report big unknown files.""" + now = time.time() + if not temp_root.is_dir(): + return + for entry in temp_root.iterdir(): + try: + if entry.is_file(): + matched = any( + fnmatch.fnmatch(entry.name, pat) for pat in TEMP_TRASH_PATTERNS + ) + size_mb = entry.stat().st_size / (1024 * 1024) + if matched and _older_than(entry, ttl_days, now): + _rmtree_or_file(entry, stats, dry_run) + elif size_mb >= REPORT_ONLY_ABOVE_MB: + stats.reported_big.append( + f"{size_mb:.1f}MB {entry} (not allowlisted — owner decision)" + ) + elif entry.is_dir(): + if any(fnmatch.fnmatch(entry.name, pat) for pat in TEMP_TRASH_PATTERNS) and _older_than( + entry, ttl_days, now + ): + _rmtree_or_file(entry, stats, dry_run) + except OSError as exc: + stats.errors.append(f"{entry}: {exc}") + + +def rotate_repo_logs(repo: Path, stats: SweepStats, dry_run: bool) -> None: + """Repo *.log > LOG_ROTATE_ABOVE_MB: move to .1 (drop old .1), keep live file.""" + for log in repo.rglob("*.log"): + if ".git" in log.parts or "venv" in log.parts or "node_modules" in log.parts: + continue + try: + if log.stat().st_size > LOG_ROTATE_ABOVE_MB * 1024 * 1024: + backup = log.with_suffix(log.suffix + ".1") + if not dry_run: + if backup.exists(): + backup.unlink() + log.rename(backup) + log.touch() + stats.rotated_logs += 1 + except OSError as exc: + stats.errors.append(f"{log}: {exc}") + + +def sweep_experiment_work( + repo: Path, ttl_days: float, stats: SweepStats, dry_run: bool +) -> None: + """Delete files under experiments/**/work older than ttl. + + Never descends into frozen/ or results/ dirs. Removes dirs that become empty. + """ + now = time.time() + exp_root = repo / "experiments" + if not exp_root.is_dir(): + return + for work in exp_root.rglob(WORK_DIRNAME): + if not work.is_dir(): + continue + if any(hint in work.parts for hint in PROTECTED_DIR_HINTS): + continue + for path in sorted(work.rglob("*"), reverse=True): + rel = path.relative_to(work) + if rel.parts and rel.parts[0] in PROTECTED_DIR_HINTS: + continue + try: + if path.is_file() and _older_than(path, ttl_days, now): + _rmtree_or_file(path, stats, dry_run) + elif path.is_dir() and not any(path.iterdir()): + if not dry_run: + path.rmdir() + except OSError as exc: + stats.errors.append(f"{path}: {exc}") + + +def free_mb(path: Path) -> float: + return shutil.disk_usage(path).free / (1024 * 1024) + + +def top_hogs(root: Path, limit: int = 10) -> list[tuple[float, str]]: + try: + entries = [ + e + for e in root.iterdir() + if not e.is_symlink() or e.is_dir() + ] + except OSError: + return [] + rows: list[tuple[float, str]] = [] + for entry in entries: + try: + if entry.is_file(): + rows.append((entry.stat().st_size / (1024 * 1024), str(entry))) + elif entry.is_dir(): + total = sum( + p.stat().st_size + for p in entry.rglob("*") + if p.is_file() + ) + rows.append((total / (1024 * 1024), str(entry))) + except OSError: + continue + return sorted(rows, reverse=True)[:limit] + + +def default_temp_root() -> Path: + return Path(tempfile.gettempdir()) / "opencode" + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--check", action="store_true", help="fail if system drive free < threshold") + ap.add_argument("--clean", action="store_true", help="apply TTL/caps cleanup") + ap.add_argument("--report", action="store_true", help="print top hogs, delete nothing") + ap.add_argument("--dry-run", action="store_true") + ap.add_argument("--min-free-gb", type=float, default=1.0) + ap.add_argument("--temp-ttl-days", type=float, default=7.0) + ap.add_argument("--work-ttl-days", type=float, default=30.0) + ap.add_argument("--repo", type=Path, default=PROJECT_ROOT) + ap.add_argument("--temp-root", type=Path, default=default_temp_root()) + ap.add_argument("--report-limit", type=int, default=10) + args = ap.parse_args(argv) + + if not (args.check or args.clean or args.report): + ap.error("one of --check/--clean/--report is required") + + rc = 0 + if args.check or args.clean: + anchors = {"repo": args.repo.resolve(), "temp": args.temp_root} + for label, anchor in anchors.items(): + if not Path(anchor).exists(): + print(f"SKIP [{label}] {anchor}: path does not exist (nothing to check)") + continue + try: + free_gb = shutil.disk_usage(os.fspath(anchor)).free / (1024**3) + except OSError as exc: + print(f"SKIP [{label}] {anchor}: cannot stat volume ({exc})") + continue + print(f"Free [{label}] {anchor}: {free_gb:.2f}GB (min {args.min_free_gb}GB)") + if free_gb < args.min_free_gb: + print(f"FAIL [{label}]: free space {free_gb:.2f}GB < {args.min_free_gb}GB minimum") + rc = 1 + if args.check: + return rc + + if args.report or args.clean: + for root in (args.temp_root, args.repo): + print(f"--- top hogs under {root} ---") + for mb, path in top_hogs(root, args.report_limit): + print(f"{mb:10.1f}MB {path}") + + if args.clean: + stats = SweepStats() + sweep_temp_opencode(args.temp_root, args.temp_ttl_days, stats, args.dry_run) + rotate_repo_logs(args.repo, stats, args.dry_run) + sweep_experiment_work(args.repo, args.work_ttl_days, stats, args.dry_run) + print( + f"{'Would delete' if args.dry_run else 'Deleted'}: " + f"{stats.deleted_files} entries, {stats.deleted_bytes / (1024 * 1024):.1f}MB; " + f"rotated logs: {stats.rotated_logs}" + ) + for line in stats.reported_big: + print(f"BIG-TICKET (left for owner): {line}") + for err in stats.errors: + print(f"ERROR: {err}") + rc = 1 + return rc + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/test_disk_guard.py b/tests/test_disk_guard.py new file mode 100644 index 00000000..d3552c3d --- /dev/null +++ b/tests/test_disk_guard.py @@ -0,0 +1,158 @@ +"""Unit tests for scripts/disk_guard.py. + +All fixtures live in tmp_path — never touches real TEMP/opencode or the repo. +""" + +from __future__ import annotations + +import os +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) + +from disk_guard import ( # noqa: E402 + SweepStats, + default_temp_root, + main, + rotate_repo_logs, + sweep_experiment_work, + sweep_temp_opencode, +) + + +def _old(path: Path, days: float = 10) -> Path: + stamp = time.time() - days * 86400 + os.utime(path, (stamp, stamp)) + return path + + +def test_temp_ttl_deletes_allowlisted_old_backup(tmp_path: Path) -> None: + backup = tmp_path / "backup_test_20200101" + backup.mkdir() + (backup / "f.txt").write_text("x") + _old(backup / "f.txt", 10) + _old(backup, 10) + stats = SweepStats() + sweep_temp_opencode(tmp_path, 7, stats, dry_run=False) + assert not backup.exists() + assert stats.deleted_files == 1 + + +def test_temp_ttl_keeps_fresh_and_non_allowlisted(tmp_path: Path) -> None: + fresh = tmp_path / "backup_fresh" + fresh.mkdir() + (fresh / "f.txt").write_text("x") # fresh mtime + db = tmp_path / "important.db" + db.write_bytes(b"\0" * 1024) # small so not big-ticket + stats = SweepStats() + sweep_temp_opencode(tmp_path, 7, stats, dry_run=False) + assert fresh.exists() + assert db.exists() + assert stats.deleted_files == 0 + + +def test_temp_reports_big_unknown_file(tmp_path: Path) -> None: + big = tmp_path / "mystery.bin" + big.write_bytes(b"\0" * 2 * 1024 * 1024) + import disk_guard + + old_limit, disk_guard.REPORT_ONLY_ABOVE_MB = disk_guard.REPORT_ONLY_ABOVE_MB, 1 + try: + stats = SweepStats() + sweep_temp_opencode(tmp_path, 7, stats, dry_run=False) + finally: + disk_guard.REPORT_ONLY_ABOVE_MB = old_limit + assert big.exists() # never auto-deleted + assert len(stats.reported_big) == 1 + + +def test_temp_ttl_deletes_stale_pytest_worker_dir(tmp_path: Path) -> None: + worker = tmp_path / "pytest-of-misha" + worker.mkdir() + (worker / "tokenizer.json").write_bytes(b"\0" * 64) + _old(worker / "tokenizer.json", 10) + _old(worker, 10) + stats = SweepStats() + sweep_temp_opencode(tmp_path, 7, stats, dry_run=False) + assert not worker.exists() + + +def test_temp_dry_run_deletes_nothing(tmp_path: Path) -> None: + log = tmp_path / "stale.log" + log.write_text("x") + _old(log, 10) + stats = SweepStats() + sweep_temp_opencode(tmp_path, 7, stats, dry_run=True) + assert log.exists() + assert stats.deleted_files == 1 # counted, not removed + + +def test_log_rotation_keeps_one_backup(tmp_path: Path) -> None: + log = tmp_path / "app.log" + log.write_bytes(b"\0" * 6 * 1024 * 1024) + stats = SweepStats() + rotate_repo_logs(tmp_path, stats, dry_run=False) + assert stats.rotated_logs == 1 + assert log.exists() and log.stat().st_size == 0 + assert log.with_suffix(".log.1").stat().st_size == 6 * 1024 * 1024 + + +def test_log_rotation_skips_small_logs(tmp_path: Path) -> None: + log = tmp_path / "small.log" + log.write_text("tiny") + stats = SweepStats() + rotate_repo_logs(tmp_path, stats, dry_run=False) + assert stats.rotated_logs == 0 + assert log.read_text() == "tiny" + + +def test_work_ttl_respects_frozen_and_results(tmp_path: Path) -> None: + exp = tmp_path / "experiments" / "e1" + work = exp / "work" + work.mkdir(parents=True) + stale = work / "ctx.txt" + stale.write_text("old") + _old(stale, 40) + frozen = work / "frozen" / "keep.txt" + frozen.parent.mkdir() + frozen.write_text("keep") + _old(frozen, 40) + results = exp / "results" / "work" / "r.txt" # protected path hint + results.parent.mkdir(parents=True) + results.write_text("keep") + stats = SweepStats() + sweep_experiment_work(tmp_path, 30, stats, dry_run=False) + assert not stale.exists() + assert frozen.exists() + assert results.exists() + + +def test_check_skips_missing_temp_anchor(tmp_path: Path, capsys) -> None: + missing = tmp_path / "no-such-temp" / "opencode" + rc = main( + ["--check", "--repo", str(tmp_path), "--temp-root", str(missing), + "--min-free-gb", "0"] + ) + assert rc == 0 # skip, not raise + out = capsys.readouterr().out + assert "SKIP [temp]" in out + + +def test_check_skips_missing_repo_anchor(tmp_path: Path, capsys) -> None: + missing_repo = tmp_path / "no-such-repo" + rc = main( + ["--check", "--repo", str(missing_repo), "--temp-root", str(tmp_path), + "--min-free-gb", "0"] + ) + assert rc == 0 # skip, not raise + out = capsys.readouterr().out + assert "SKIP [repo]" in out + + +def test_default_temp_root_uses_gettempdir(tmp_path: Path, monkeypatch) -> None: + import disk_guard + + monkeypatch.setattr(disk_guard.tempfile, "gettempdir", lambda: str(tmp_path)) + assert default_temp_root() == tmp_path / "opencode"