#!/usr/bin/env python3 from __future__ import annotations import argparse import fcntl import json import socket import sys from contextlib import contextmanager from pathlib import Path from typing import Any DEFAULT_REGISTRY = Path(__file__).resolve().parent / "runtime" / "registry" / "instances.json" def _default_data() -> dict[str, Any]: return {"version": 1, "instances": []} def _normalize_record(record: dict[str, Any]) -> dict[str, Any]: normalized = dict(record) normalized["host_port"] = int(record.get("host_port", 0) or 0) for key in ( "instance_id", "instance_slug", "container_name", "image_name", "public_url", "instance_root", "beaver_home", "config_path", "auth_users_path", "network_name", "backend_id", "backend_name", "authz_base_url", "created_at", "username", "email", "instance_host", "frontend_base_url", "api_base_url", ): normalized[key] = str(record.get(key, "") or "") return normalized def read_registry(path: Path) -> dict[str, Any]: if not path.exists(): return _default_data() try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: return _default_data() if not isinstance(data, dict): return _default_data() if not isinstance(data.get("instances"), list): data["instances"] = [] data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)] return data @contextmanager def locked_registry(path: Path): path.parent.mkdir(parents=True, exist_ok=True) lock_path = path.with_suffix(".lock") lock_path.touch(exist_ok=True) with lock_path.open("r+", encoding="utf-8") as lock_file: fcntl.flock(lock_file.fileno(), fcntl.LOCK_EX) if path.exists(): try: data = json.loads(path.read_text(encoding="utf-8")) except json.JSONDecodeError: data = _default_data() else: data = _default_data() if not isinstance(data, dict): data = _default_data() if not isinstance(data.get("instances"), list): data["instances"] = [] data["instances"] = [_normalize_record(item) for item in data["instances"] if isinstance(item, dict)] try: yield data finally: path.write_text(json.dumps(data, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") fcntl.flock(lock_file.fileno(), fcntl.LOCK_UN) def _match( record: dict[str, Any], *, instance_id: str | None, slug: str | None, container_name: str | None, username: str | None, instance_host: str | None, ) -> bool: if instance_id and record.get("instance_id") == instance_id: return True if slug and record.get("instance_slug") == slug: return True if container_name and record.get("container_name") == container_name: return True if username and record.get("username") == username: return True if instance_host and record.get("instance_host") == instance_host: return True return False def _get_record( data: dict[str, Any], *, instance_id: str | None, slug: str | None, container_name: str | None, username: str | None, instance_host: str | None, ) -> dict[str, Any] | None: for item in data["instances"]: if _match( item, instance_id=instance_id, slug=slug, container_name=container_name, username=username, instance_host=instance_host, ): return item return None def cmd_list(args: argparse.Namespace) -> int: path = Path(args.registry).expanduser() data = read_registry(path) instances = list(data["instances"]) if args.json: json.dump({"instances": instances}, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 for item in instances: print( "\t".join( [ str(item.get("instance_id", "")), str(item.get("instance_slug", "")), str(item.get("container_name", "")), str(item.get("host_port", "")), str(item.get("public_url", "")), str(item.get("instance_root", "")), ] ) ) return 0 def cmd_get(args: argparse.Namespace) -> int: path = Path(args.registry).expanduser() data = read_registry(path) record = _get_record( data, instance_id=args.instance_id, slug=args.slug, container_name=args.container_name, username=args.username, instance_host=args.instance_host, ) if record is None: return 1 json.dump(record, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 def cmd_upsert(args: argparse.Namespace) -> int: path = Path(args.registry).expanduser() record = { "instance_id": args.instance_id, "instance_slug": args.instance_slug, "container_name": args.container_name, "image_name": args.image_name, "host_port": int(args.host_port), "public_url": args.public_url, "instance_root": args.instance_root, "beaver_home": args.beaver_home, "config_path": args.config_path, "auth_users_path": args.auth_users_path, "network_name": args.network_name or "", "backend_id": args.backend_id or "", "backend_name": args.backend_name or "", "authz_base_url": args.authz_base_url or "", "username": args.username or "", "email": args.email or "", "instance_host": args.instance_host or "", "frontend_base_url": args.frontend_base_url or "", "api_base_url": args.api_base_url or "", "created_at": args.created_at, } with locked_registry(path) as data: kept: list[dict[str, Any]] = [] for item in data["instances"]: if _match( item, instance_id=args.instance_id, slug=args.instance_slug, container_name=args.container_name, username=args.username, instance_host=args.instance_host, ): continue kept.append(item) kept.append(_normalize_record(record)) kept.sort(key=lambda item: str(item.get("instance_id", ""))) data["instances"] = kept return 0 def cmd_remove(args: argparse.Namespace) -> int: path = Path(args.registry).expanduser() removed = False removed_record: dict[str, Any] | None = None with locked_registry(path) as data: kept: list[dict[str, Any]] = [] for item in data["instances"]: if removed: kept.append(item) continue if _match( item, instance_id=args.instance_id, slug=args.slug, container_name=args.container_name, username=args.username, instance_host=args.instance_host, ): removed = True removed_record = item continue kept.append(item) data["instances"] = kept if not removed: return 1 if args.print_removed and removed_record is not None: json.dump(removed_record, sys.stdout, indent=2, ensure_ascii=False) sys.stdout.write("\n") return 0 def cmd_next_port(args: argparse.Namespace) -> int: path = Path(args.registry).expanduser() with locked_registry(path) as data: reserved = { int(item.get("host_port", 0)) for item in data["instances"] if int(item.get("host_port", 0) or 0) > 0 and item.get("instance_id") != (args.exclude_instance_id or "") } for port in range(args.start, args.end + 1): if port in reserved: continue with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) try: sock.bind((args.host, port)) except OSError: continue print(port) return 0 print(f"no free port in range {args.start}-{args.end}", file=sys.stderr) return 1 def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="Manage app-instance registry.") parser.set_defaults(func=None) parser.add_argument("--registry", default=str(DEFAULT_REGISTRY), help="Registry JSON path.") subparsers = parser.add_subparsers(dest="command") list_parser = subparsers.add_parser("list", help="List registered instances.") list_parser.add_argument("--json", action="store_true", help="Output JSON.") list_parser.set_defaults(func=cmd_list) get_parser = subparsers.add_parser("get", help="Get one registered instance.") get_parser.add_argument("--instance-id") get_parser.add_argument("--slug") get_parser.add_argument("--container-name") get_parser.add_argument("--username") get_parser.add_argument("--instance-host") get_parser.set_defaults(func=cmd_get) upsert_parser = subparsers.add_parser("upsert", help="Create or update one registry record.") upsert_parser.add_argument("--instance-id", required=True) upsert_parser.add_argument("--instance-slug", required=True) upsert_parser.add_argument("--container-name", required=True) upsert_parser.add_argument("--image-name", required=True) upsert_parser.add_argument("--host-port", required=True, type=int) upsert_parser.add_argument("--public-url", required=True) upsert_parser.add_argument("--instance-root", required=True) upsert_parser.add_argument("--beaver-home", required=True) upsert_parser.add_argument("--config-path", required=True) upsert_parser.add_argument("--auth-users-path", required=True) upsert_parser.add_argument("--network-name", default="") upsert_parser.add_argument("--backend-id", default="") upsert_parser.add_argument("--backend-name", default="") upsert_parser.add_argument("--authz-base-url", default="") upsert_parser.add_argument("--username", default="") upsert_parser.add_argument("--email", default="") upsert_parser.add_argument("--instance-host", default="") upsert_parser.add_argument("--frontend-base-url", default="") upsert_parser.add_argument("--api-base-url", default="") upsert_parser.add_argument("--created-at", required=True) upsert_parser.set_defaults(func=cmd_upsert) remove_parser = subparsers.add_parser("remove", help="Remove one registry record.") remove_parser.add_argument("--instance-id") remove_parser.add_argument("--slug") remove_parser.add_argument("--container-name") remove_parser.add_argument("--username") remove_parser.add_argument("--instance-host") remove_parser.add_argument("--print-removed", action="store_true") remove_parser.set_defaults(func=cmd_remove) port_parser = subparsers.add_parser("next-port", help="Find next free host port.") port_parser.add_argument("--host", default="127.0.0.1") port_parser.add_argument("--start", default=20000, type=int) port_parser.add_argument("--end", default=29999, type=int) port_parser.add_argument("--exclude-instance-id", default="") port_parser.set_defaults(func=cmd_next_port) return parser def main() -> int: parser = build_parser() args = parser.parse_args() if args.func is None: parser.print_help() return 1 return int(args.func(args) or 0) if __name__ == "__main__": raise SystemExit(main())