"""``retention`` sub-commands. Ports ``scripts/Invoke-RetentionPolicy.ps1`` to Python. * ``retention run`` — purge old artifact and log directories based on age; switches to aggressive retention when disk free space is low. """ from __future__ import annotations import shutil from datetime import UTC, datetime, timedelta from pathlib import Path import click from ci_orchestrator.config import load_config @click.group() def retention() -> None: """Maintenance: artifact and log retention.""" @retention.command("run") @click.option( "--artifact-dir", default=None, help="Artifact directory (default: CI_ARTIFACTS from config/env).", ) @click.option( "--log-dir", default=None, help="Log directory (default: /logs).", ) @click.option( "--retention-days", default=30, show_default=True, help="Normal retention threshold in days.", ) @click.option( "--aggressive-retention-days", default=7, show_default=True, help="Retention threshold when disk free space is below --min-free-gb.", ) @click.option( "--min-free-gb", default=50, show_default=True, help="Switch to aggressive retention when free space drops below this value (GB).", ) @click.option( "--what-if", is_flag=True, help="Dry run — log what would be deleted without actually deleting.", ) def retention_run( artifact_dir: str | None, log_dir: str | None, retention_days: int, aggressive_retention_days: int, min_free_gb: int, what_if: bool, ) -> None: """Purge old artifact and log directories based on retention policy.""" config = load_config() art_path = Path(artifact_dir) if artifact_dir else config.paths.artifacts log_path = Path(log_dir) if log_dir else config.paths.root / "logs" check_path = art_path if art_path.exists() else config.paths.root usage = shutil.disk_usage(str(check_path)) free_gb = usage.free / (1024**3) aggressive = free_gb < min_free_gb effective_days = aggressive_retention_days if aggressive else retention_days cutoff = datetime.now(tz=UTC) - timedelta(days=effective_days) click.echo(f"[RetentionPolicy] Free space: {free_gb:.1f} GB") if aggressive: click.echo( f"[RetentionPolicy] WARNING: Free space below {min_free_gb} GB — " f"aggressive retention: {aggressive_retention_days}d", err=True, ) else: click.echo( f"[RetentionPolicy] Normal retention: {effective_days}d " f"(cutoff: {cutoff.strftime('%Y-%m-%d')})" ) _purge_old_dirs(art_path, "artifact", cutoff, what_if=what_if) _purge_old_dirs(log_path, "log", cutoff, what_if=what_if) usage_after = shutil.disk_usage(str(check_path)) free_gb_after = usage_after.free / (1024**3) delta = free_gb_after - free_gb click.echo( f"[RetentionPolicy] Done. Free space: {free_gb_after:.1f} GB (delta: +{delta:.1f} GB)" ) def _purge_old_dirs(base: Path, label: str, cutoff: datetime, *, what_if: bool) -> None: if not base.exists(): click.echo(f"[RetentionPolicy] {label} dir not found: {base} — skipping.") return old = [ d for d in base.iterdir() if d.is_dir() and datetime.fromtimestamp(d.stat().st_mtime, tz=UTC) < cutoff ] if not old: click.echo( f"[RetentionPolicy] {label}: nothing to purge " f"(cutoff: {cutoff.strftime('%Y-%m-%d')})." ) return for d in old: age_days = int( (datetime.now(tz=UTC) - datetime.fromtimestamp(d.stat().st_mtime, tz=UTC)).days ) if what_if: click.echo( f"[RetentionPolicy] WhatIf: would purge {label} ({age_days}d): {d.name}" ) else: shutil.rmtree(d, ignore_errors=True) click.echo(f"[RetentionPolicy] Purged {label} ({age_days}d): {d.name}")