43a69b82db
- Add `retention run` command (ports Invoke-RetentionPolicy.ps1): purges old artifact/log dirs with aggressive mode when disk space is low. - Add `template backup` command (ports Backup-CITemplate.ps1): 7z -mx=1 compressed archives in /var/lib/ci/backups/, prunes to keep-count=3, stops/restarts act-runner.service around the copy. - Update ci-retention-policy.service and ci-backup-template.service to use Python; pwsh is no longer required on the Linux host. - Fix ci-watch-runner-health.service: pass --service-name act-runner (Linux service name, not Windows act_runner default). - Fix _list_orphans in vm.py: wrap is_dir() inside the OSError try block so a stat() failure on an entry is silently skipped rather than raised. - Mark B5 complete in PhaseB-user-checklist.md. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
135 lines
3.9 KiB
Python
135 lines
3.9 KiB
Python
"""``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: <CI_ROOT>/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}")
|