#!/usr/bin/env python3 """Delete inactive Prod D3 release roots outside the retention window.""" from __future__ import annotations import argparse import json import shutil import sys from dataclasses import dataclass from pathlib import Path from typing import Any try: from .prod_d3_authority import CURRENT_SCHEMA, IN_PROGRESS_SCHEMA except ImportError: # Direct script execution adds this directory to sys.path. from prod_d3_authority import CURRENT_SCHEMA, IN_PROGRESS_SCHEMA DEFAULT_MULTI_PROD_ROOT = Path( "/home/ubuntu/nexuscrm-deployment/online/envs/multi_prod" ) @dataclass(frozen=True) class CleanupAction: path: Path size_bytes: int reason: str device: int inode: int def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser( description="Cleanup inactive multi_prod D3 release directories." ) parser.add_argument( "--multi-prod-root", type=Path, default=DEFAULT_MULTI_PROD_ROOT, help="multi_prod environment root", ) parser.add_argument( "--keep-inactive-releases", type=int, default=3, help="inactive D3 release directories retained in addition to current/pending", ) parser.add_argument("--dry-run", action="store_true") parser.add_argument("--summary-json", type=Path) return parser.parse_args() def log(message: str) -> None: print(message, file=sys.stderr) def resolved(path: Path) -> Path: return path.expanduser().resolve(strict=False) def require_managed_directory(path: Path, label: str) -> Path: expanded = path.expanduser() if expanded.is_symlink(): raise RuntimeError(f"{label} must not be a symlink: {expanded}") if expanded.exists() and not expanded.is_dir(): raise RuntimeError(f"{label} is not a directory: {expanded}") return resolved(expanded) def require_release_child(path: Path, releases_root: Path, label: str) -> Path: candidate = resolved(path) root = resolved(releases_root) if candidate.parent != root: raise RuntimeError(f"{label} escaped Prod D3 releases root: {candidate}") return candidate def read_json_authority(path: Path, label: str) -> dict[str, Any]: if path.is_symlink() or not path.is_file(): raise RuntimeError(f"{label} must be a regular file: {path}") try: value = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError) as exc: raise RuntimeError(f"{label} is invalid: {path}") from exc if not isinstance(value, dict): raise RuntimeError(f"{label} must be a JSON object: {path}") return value def marker_release_root( marker: dict[str, Any], *, expected_schema: str, releases_root: Path, label: str, ) -> tuple[str, Path]: release_tag = str(marker.get("releaseTag", "")).strip() release_root = str(marker.get("releaseRoot", "")).strip() if marker.get("schema") != expected_schema or not release_tag or not release_root: raise RuntimeError(f"{label} has an invalid release identity") protected = require_release_child(Path(release_root), releases_root, label) if protected.name != release_tag: raise RuntimeError(f"{label} releaseTag disagrees with releaseRoot") return release_tag, protected def protected_release_roots( d3_root: Path, releases_root: Path ) -> tuple[str | None, str | None, set[Path]]: protected: set[Path] = set() current_tag: str | None = None pending_tag: str | None = None current_link = d3_root / "current" current_marker = d3_root / "current.json" if current_link.exists() or current_link.is_symlink(): if not current_link.is_symlink(): raise RuntimeError(f"Prod D3 current pointer is not a symlink: {current_link}") link_target = require_release_child(current_link, releases_root, "Prod D3 current") if not link_target.is_dir(): raise RuntimeError(f"Prod D3 current target is unavailable: {link_target}") marker = read_json_authority(current_marker, "Prod D3 current marker") current_tag, marker_target = marker_release_root( marker, expected_schema=CURRENT_SCHEMA, releases_root=releases_root, label="Prod D3 current marker", ) if marker_target != link_target: raise RuntimeError("Prod D3 current pointer disagrees with current.json") protected.add(link_target) elif current_marker.exists() or current_marker.is_symlink(): raise RuntimeError("Prod D3 current marker exists without current pointer") in_progress = d3_root / "in-progress.json" if in_progress.exists() or in_progress.is_symlink(): marker = read_json_authority(in_progress, "Prod D3 in-progress marker") pending_tag, pending_target = marker_release_root( marker, expected_schema=IN_PROGRESS_SCHEMA, releases_root=releases_root, label="Prod D3 in-progress marker", ) if not pending_target.is_dir(): raise RuntimeError( f"Prod D3 in-progress release root is unavailable: {pending_target}" ) protected.add(pending_target) return current_tag, pending_tag, protected def path_size_bytes(path: Path) -> int: total = 0 for child in path.rglob("*"): try: if child.is_file() and not child.is_symlink(): total += child.stat().st_size except OSError: continue return total def format_bytes(size_bytes: int) -> str: units = ("B", "KB", "MB", "GB", "TB") value = float(size_bytes) for unit in units: if value < 1024 or unit == units[-1]: return f"{int(value)}{unit}" if unit == "B" else f"{value:.1f}{unit}" value /= 1024 return f"{size_bytes}B" def cleanup_action(path: Path) -> CleanupAction: identity = path.lstat() return CleanupAction( path=path, size_bytes=path_size_bytes(path), reason="inactive Prod D3 release outside retention window", device=identity.st_dev, inode=identity.st_ino, ) def cleanup_plan( multi_prod_root: Path, keep_inactive_releases: int ) -> tuple[dict[str, Any], list[CleanupAction]]: if keep_inactive_releases < 0: raise RuntimeError("keep-inactive-releases must be non-negative") managed_root = require_managed_directory(multi_prod_root, "multi_prod root") d3_root = require_managed_directory(managed_root / "d3", "Prod D3 root") releases_root = require_managed_directory( d3_root / "releases", "Prod D3 releases root" ) current_tag, pending_tag, protected = protected_release_roots( d3_root, releases_root ) release_dirs: list[Path] = [] if releases_root.is_dir(): for path in releases_root.iterdir(): if path.is_symlink(): raise RuntimeError(f"Prod D3 release root must not be a symlink: {path}") if path.is_dir(): release_dirs.append(require_release_child(path, releases_root, "release")) release_dirs.sort(key=lambda path: path.name) if release_dirs and current_tag is None: raise RuntimeError( "Prod D3 releases exist without durable current authority" ) inactive = [path for path in release_dirs if path not in protected] kept_inactive = set( inactive[-keep_inactive_releases:] if keep_inactive_releases else [] ) actions = [ cleanup_action(path) for path in inactive if path not in kept_inactive ] summary = { "target": "prod-d3", "multi_prod_root": str(managed_root), "d3_root": str(d3_root), "releases_root": str(releases_root), "active_release_tag": current_tag, "in_progress_release_tag": pending_tag, "keep_inactive_releases": keep_inactive_releases, "release_count": len(release_dirs), "inactive_release_count": len(inactive), "protected_paths": sorted(str(path) for path in protected), "kept_inactive_release_tags": sorted(path.name for path in kept_inactive), } return summary, actions def execute( actions: list[CleanupAction], *, d3_root: Path, releases_root: Path, dry_run: bool, ) -> tuple[int, int]: deleted = 0 reclaimed = 0 for action in actions: if dry_run: log(f"[dry-run] delete Prod D3 release: {action.path}") reclaimed += action.size_bytes continue live_d3_root = require_managed_directory(d3_root, "Prod D3 root") live_releases_root = require_managed_directory( releases_root, "Prod D3 releases root" ) if live_d3_root != d3_root or live_releases_root != releases_root: raise RuntimeError("Prod D3 managed roots changed before deletion") current_tag, _pending_tag, protected = protected_release_roots( live_d3_root, live_releases_root ) if current_tag is None: raise RuntimeError( "refusing destructive cleanup without durable Prod D3 current authority" ) candidate = require_release_child( action.path, live_releases_root, "planned Prod D3 release" ) if candidate in protected: raise RuntimeError( f"planned Prod D3 release became protected before deletion: {candidate}" ) if candidate.is_symlink() or not candidate.is_dir(): raise RuntimeError( f"planned Prod D3 release changed before deletion: {candidate}" ) candidate_stat = candidate.lstat() if (candidate_stat.st_dev, candidate_stat.st_ino) != ( action.device, action.inode, ): raise RuntimeError( f"planned Prod D3 release identity changed before deletion: {candidate}" ) shutil.rmtree(candidate) log(f"deleted Prod D3 release: {action.path}") deleted += 1 reclaimed += action.size_bytes return deleted, reclaimed def run_cleanup( *, multi_prod_root: Path, keep_inactive_releases: int, dry_run: bool ) -> dict[str, Any]: summary, actions = cleanup_plan(multi_prod_root, keep_inactive_releases) deleted, reclaimed = execute( actions, d3_root=Path(str(summary["d3_root"])), releases_root=Path(str(summary["releases_root"])), dry_run=dry_run, ) return { **summary, "dry_run": dry_run, "planned_delete_count": len(actions), "deleted_count": deleted, "reclaimed_bytes": reclaimed, "reclaimed_human": format_bytes(reclaimed), "actions": [ { "category": "prod_d3_release", "path": str(action.path), "size_bytes": action.size_bytes, "size_human": format_bytes(action.size_bytes), "reason": action.reason, } for action in actions ], } def main() -> int: args = parse_args() payload = run_cleanup( multi_prod_root=args.multi_prod_root, keep_inactive_releases=args.keep_inactive_releases, dry_run=bool(args.dry_run), ) if args.summary_json: args.summary_json.parent.mkdir(parents=True, exist_ok=True) args.summary_json.write_text( json.dumps(payload, indent=2, sort_keys=True) + "\n", encoding="utf-8", ) print(json.dumps(payload, indent=2, sort_keys=True)) return 0 if __name__ == "__main__": raise SystemExit(main())