#!/usr/bin/env python3 """Small DevOps Portal REST helper for Investibook agent skills.""" from __future__ import annotations import argparse import json import os import sys import urllib.error import urllib.parse import urllib.request from typing import Any DEFAULT_BASE_URL = "https://prod.devops.pingcap.com" DEFAULT_FRONTEND_URL = "https://ops.tidbcloud.com" class DevOpsConfigError(RuntimeError): pass def _env_first(*names: str, default: str = "") -> str: for name in names: value = os.environ.get(name) if value: return value.strip() return default def load_config() -> tuple[str, str, str]: base_url = _env_first("DEVOPS_BASE_URL", default=DEFAULT_BASE_URL).rstrip("/") frontend_url = _env_first( "DEVOPS_FRONTEND_URL", default=_default_frontend_url(base_url), ).rstrip("/") api_key = _env_first("DEVOPS_API_KEY", "DEVOPS_API_TOKEN") if not api_key: raise DevOpsConfigError( "DevOps credentials are missing: set DEVOPS_API_KEY or DEVOPS_API_TOKEN." ) return base_url, api_key, frontend_url def _default_frontend_url(base_url: str) -> str: normalized = str(base_url or "").strip().rstrip("/") if "staging.devops" in normalized: return "https://ops-staging.tidbcloud.com" if "dev.devops" in normalized: return "https://ops-dev.tidbcloud.com" return DEFAULT_FRONTEND_URL def devops_request(path: str, *, params: dict[str, Any] | None = None) -> dict[str, Any]: base_url, api_key, _ = load_config() api_path = path.lstrip("/") query = urllib.parse.urlencode(params or {}, doseq=True) url = f"{base_url}/api/v1/devops/{api_path}" if query: url = f"{url}?{query}" request = urllib.request.Request( url=url, headers={ "Accept": "application/json", "X-API-KEY": api_key, }, method="GET", ) with urllib.request.urlopen(request, timeout=30) as response: raw = response.read().decode("utf-8") if not raw: return {} return json.loads(raw) def _change_url(change_id: Any) -> str: _, _, frontend_url = load_config() return f"{frontend_url}/confighistories/{change_id}" def _add_change_urls(payload: dict[str, Any]) -> dict[str, Any]: for item in payload.get("items") or []: if isinstance(item, dict) and item.get("id") is not None: item.setdefault("url", _change_url(item["id"])) change = payload.get("change") if isinstance(change, dict) and change.get("id") is not None: change.setdefault("url", _change_url(change["id"])) return payload def get_tenant(args: argparse.Namespace) -> dict[str, Any]: return devops_request(f"/tenants/{args.tenant_id}") def query_tenants(args: argparse.Namespace) -> dict[str, Any]: params: dict[str, Any] = { "search": args.search, "page": args.page, "per_page": args.per_page, } if args.status: params["status"] = args.status return devops_request("/tenants", params=params) def list_cloudconfig_changes(args: argparse.Namespace) -> dict[str, Any]: params: dict[str, Any] = { "cluster_type": args.cluster_type, "cluster_id": args.cluster_id, "page": args.page, "per_page": args.per_page, } if args.id_only: params["id_only"] = "true" if args.have_modified_param_like: params["have_modified_param_like"] = args.have_modified_param_like return _add_change_urls(devops_request("/cloudconfig/cluster/changes", params=params)) def get_cloudconfig_change(args: argparse.Namespace) -> dict[str, Any]: return _add_change_urls(devops_request(f"/cloudconfig/changes/{args.change_id}")) def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser(description="DevOps Portal REST helper") sub = parser.add_subparsers(dest="command", required=True) p_get = sub.add_parser("get-tenant", help="Fetch one tenant/org by ID") p_get.add_argument("--tenant-id", required=True) p_get.set_defaults(func=get_tenant) p_query = sub.add_parser("query-tenants", help="Search tenants/orgs by name") p_query.add_argument("--search", required=True) p_query.add_argument("--page", type=int, default=1) p_query.add_argument("--per-page", type=int, default=20) p_query.add_argument("--status", default="active") p_query.set_defaults(func=query_tenants) p_list_changes = sub.add_parser( "list-cloudconfig-changes", help="List read-only Config Center changes for a cluster", ) p_list_changes.add_argument("--cluster-id", required=True) p_list_changes.add_argument("--cluster-type", default="DEDICATED_TIDB") p_list_changes.add_argument("--page", type=int, default=1) p_list_changes.add_argument("--per-page", type=int, default=20) p_list_changes.add_argument("--id-only", action="store_true") p_list_changes.add_argument("--have-modified-param-like", default="") p_list_changes.set_defaults(func=list_cloudconfig_changes) p_get_change = sub.add_parser( "get-cloudconfig-change", help="Fetch one read-only Config Center change by ID", ) p_get_change.add_argument("--change-id", required=True) p_get_change.set_defaults(func=get_cloudconfig_change) return parser def main() -> int: parser = build_parser() args = parser.parse_args() try: result = args.func(args) except DevOpsConfigError as exc: print(f"ERROR: {exc}", file=sys.stderr) return 2 except urllib.error.HTTPError as exc: body = exc.read().decode("utf-8", errors="replace") print(f"ERROR: DevOps HTTP {exc.code}: {body}", file=sys.stderr) return 1 except urllib.error.URLError as exc: print(f"ERROR: DevOps request failed: {exc}", file=sys.stderr) return 1 print(json.dumps(result, indent=2, ensure_ascii=False)) return 0 if __name__ == "__main__": raise SystemExit(main())