97 lines
3.4 KiB
Python
97 lines
3.4 KiB
Python
|
|
#!/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()
|