#!/usr/bin/env python3 from __future__ import annotations import argparse import json import os import sys from pathlib import Path from typing import Any from urllib import error as urllib_error from urllib import parse as urllib_parse from urllib import request as urllib_request ROOT_DIR = Path(__file__).resolve().parents[1] DEFAULT_REGISTRY = ROOT_DIR / "app-instance" / "runtime" / "registry" / "instances.json" SAFE_PREFIXES = ("smoke", "test", "debug") def parse_args() -> argparse.Namespace: parser = argparse.ArgumentParser(description="Dry-run or purge Beaver smoke/test users.") parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Path to app-instance registry JSON.") parser.add_argument("--deploy-control-url", default=os.getenv("DEPLOY_CONTROL_URL", "http://127.0.0.1:8090")) parser.add_argument("--token", default=os.getenv("DEPLOY_CONTROL_API_TOKEN", "")) parser.add_argument("--backend-id", action="append", default=[], help="Explicit backend id to clean.") parser.add_argument("--instance-id", action="append", default=[], help="Explicit instance id to clean.") parser.add_argument("--username-prefix", action="append", default=[], help="Safe test username prefix, e.g. smoke.") parser.add_argument("--execute", action="store_true", help="Actually delete matched instances.") parser.add_argument("--purge-data", action="store_true", help="Delete local instance data when executing.") parser.add_argument( "--keep-user-files", action="store_true", help="Do not request MinIO/user-file purge when executing.", ) parser.add_argument( "--allow-any-prefix", action="store_true", help="Allow non-test username prefixes. Use only for controlled maintenance.", ) return parser.parse_args() def load_registry(path: Path) -> list[dict[str, Any]]: if not path.exists(): return [] payload = json.loads(path.read_text(encoding="utf-8")) instances = payload.get("instances", []) if isinstance(payload, dict) else [] return [item for item in instances if isinstance(item, dict)] def selected_instances(args: argparse.Namespace) -> list[dict[str, Any]]: instances = load_registry(Path(args.registry).expanduser()) explicit_backend_ids = {item.strip() for item in args.backend_id if item.strip()} explicit_instance_ids = {item.strip() for item in args.instance_id if item.strip()} prefixes = tuple(item.strip() for item in args.username_prefix if item.strip()) if not explicit_backend_ids and not explicit_instance_ids and not prefixes: raise SystemExit("Refusing cleanup: provide --backend-id, --instance-id, or --username-prefix.") unsafe = [prefix for prefix in prefixes if not prefix.startswith(SAFE_PREFIXES)] if unsafe and not args.allow_any_prefix: raise SystemExit( "Refusing cleanup: username prefixes must start with " f"{', '.join(SAFE_PREFIXES)} unless --allow-any-prefix is set." ) matches: list[dict[str, Any]] = [] for item in instances: instance_id = str(item.get("instance_id", "") or "") backend_id = str(item.get("backend_id", "") or "") username = str(item.get("username", "") or "") if instance_id in explicit_instance_ids or backend_id in explicit_backend_ids: matches.append(item) continue if prefixes and any(username.startswith(prefix) or backend_id.startswith(prefix) for prefix in prefixes): matches.append(item) return matches def delete_instance(args: argparse.Namespace, instance_id: str) -> dict[str, Any]: base_url = args.deploy_control_url.rstrip("/") url = f"{base_url}/api/instances/{urllib_parse.quote(instance_id, safe='')}" headers = {"Accept": "application/json"} if args.token.strip(): headers["Authorization"] = f"Bearer {args.token.strip()}" if args.purge_data: headers["X-Purge-Data"] = "1" if not args.keep_user_files: headers["X-Purge-User-Files"] = "1" request = urllib_request.Request(url, method="DELETE", headers=headers) try: with urllib_request.urlopen(request, timeout=120) as response: raw = response.read().decode("utf-8") except urllib_error.HTTPError as exc: detail = exc.reason try: payload = json.loads(exc.read().decode("utf-8")) if isinstance(payload, dict): detail = str(payload.get("detail") or detail) except Exception: pass return {"ok": False, "instance_id": instance_id, "error": detail, "status_code": exc.code} except urllib_error.URLError as exc: return {"ok": False, "instance_id": instance_id, "error": str(exc)} if not raw.strip(): return {"ok": True, "instance_id": instance_id} try: payload = json.loads(raw) except json.JSONDecodeError: return {"ok": False, "instance_id": instance_id, "error": "deploy-control response was not valid JSON"} return payload if isinstance(payload, dict) else {"ok": False, "instance_id": instance_id, "error": "unexpected response"} def main() -> int: args = parse_args() matches = selected_instances(args) planned = [ { "instance_id": str(item.get("instance_id", "") or ""), "backend_id": str(item.get("backend_id", "") or ""), "username": str(item.get("username", "") or ""), "instance_root": str(item.get("instance_root", "") or ""), } for item in matches ] if not args.execute: print(json.dumps({"dry_run": True, "count": len(planned), "planned": planned}, indent=2, ensure_ascii=False)) return 0 results = [] for item in planned: instance_id = item["instance_id"] if not instance_id: results.append({"ok": False, "error": "matched registry record is missing instance_id", "record": item}) continue results.append(delete_instance(args, instance_id)) ok = all(bool(item.get("ok")) for item in results) print(json.dumps({"dry_run": False, "count": len(results), "ok": ok, "results": results}, indent=2, ensure_ascii=False)) return 0 if ok else 1 if __name__ == "__main__": raise SystemExit(main())