#!/usr/bin/env python3 """Remove only expired orphan pod-log directories; never read log contents.""" from __future__ import annotations import argparse import os from pathlib import Path import re import shutil import time UID = re.compile(r"[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}") def walk_error(error: OSError) -> None: """A failed directory scan cannot establish that all logs are expired.""" raise error def newest_mtime(path: Path) -> float: """Inspect timestamps without following symlinks or reading file contents.""" newest = path.stat().st_mtime for root, directories, files in os.walk(path, onerror=walk_error, followlinks=False): for name in directories + files: newest = max(newest, (Path(root) / name).lstat().st_mtime) return newest def cleanup(host_root: Path, retention_days: int, dry_run: bool = False) -> dict[str, int]: """Return counts after pruning inactive UID directories older than retention. An unreadable or empty kubelet inventory cannot authorize any deletion. Recent files preserve a directory even when its own timestamp is old. """ if retention_days < 1: raise ValueError("Pod-log retention must be at least one day") result = {"removed": 0, "eligible": 0, "skipped": 0, "inventory_unavailable": 0} try: active = {p.name for p in (host_root / "var/lib/kubelet/pods").iterdir() if UID.fullmatch(p.name)} except OSError: active = set() if not active: result["inventory_unavailable"] = 1 return result cutoff = time.time() - retention_days * 86400 for relative in ("var/log/pods", "var/log.hdd/pods"): try: candidates = list((host_root / relative).iterdir()) except OSError: continue for path in candidates: uid = path.name.rsplit("_", 1)[-1] if not UID.fullmatch(uid) or uid in active or path.is_symlink(): result["skipped"] += 1 continue try: if not path.is_dir(): continue if newest_mtime(path) >= cutoff: result["skipped"] += 1 continue # Recheck the exact UID immediately before the destructive step. current = {p.name for p in (host_root / "var/lib/kubelet/pods").iterdir() if UID.fullmatch(p.name)} if not current: result["inventory_unavailable"] = 1 return result if uid in current: result["skipped"] += 1 continue result["eligible"] += 1 if not dry_run: shutil.rmtree(path) result["removed"] += 1 except OSError: result["skipped"] += 1 return result def main() -> None: """Run the configured host-root cleanup and print counts, not log paths.""" import json parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--host-root", type=Path, default=Path("/host")) parser.add_argument("--retention-days", type=int, default=3) parser.add_argument("--dry-run", action="store_true") args = parser.parse_args() print(json.dumps(cleanup(args.host_root, args.retention_days, args.dry_run))) if __name__ == "__main__": main()