"""Resolve cluster-specific metrics URL from O11Y GS clusters API.""" from __future__ import annotations import re import httpx from pydantic import BaseModel from src.models.resolve_metrics_url import ResolveMetricsUrlRequest, ResolveMetricsUrlResponse from src.services.auth import ( get_observability_global_service_url, get_server_auth0_token, get_valid_server_auth, ) from src.utils.cloud_access_store import get_entry, set_metrics_resolution from src.utils.errors import AuthServiceError, NetworkError from src.utils.logging_utils import get_logger GS_CLUSTERS_PATH = "api/v1/global/clusters/" # O11Y DS host examples: # - www.ds.us-west-2.aws.observability.tidbcloud.com # - www.ds.cn-hangzhou.alicloud.observability.tidbcloud.com # - www.ds.us-central1.gcp.observability.tidbcloud.com LOGICAL_REGION_PATTERN = re.compile( r"^www\.ds\.(?P[^.]+)\.(?Paws|alicloud|gcp)\.observability\.tidbcloud\.com$" ) RESOLVE_TIMEOUT = 15.0 logger = get_logger("observability_resolve") def _build_gs_clusters_url() -> str: base_url = get_observability_global_service_url() if not base_url: raise AuthServiceError( "Observability Global Service URL is not configured. " "Complete server-owned auth bootstrap first." ) return base_url.rstrip("/") + "/" + GS_CLUSTERS_PATH async def get_or_resolve_metrics_url( cluster_id: str, env: str, deploy_type: str, ) -> ResolveMetricsUrlResponse: """ Return cached metrics URL for the cluster, or resolve from Observability Global Service and cache. Cache is stored in cloud_access_store by (cluster_id, env, deploy_type). Args: cluster_id: Cluster identifier. env: Environment identifier (e.g. prod/staging/dev). deploy_type: Deploy type used for biz_type mapping. Returns: ResolveMetricsUrlResponse with url and optional token. Raises: AuthServiceError: If observability URL is not set or Global Service returns an error. NetworkError: If the Global Service is unreachable. """ entry = get_entry( cluster_id=cluster_id, env=env, deploy_type=deploy_type, ) if entry is not None and entry.metrics_url: logger.info( "metrics_url_cache_hit cluster_id=%s env=%s deploy_type=%s", cluster_id, env, deploy_type, ) return ResolveMetricsUrlResponse( url=entry.metrics_url, token=entry.metrics_token, ) logger.info( "metrics_url_cache_miss cluster_id=%s env=%s deploy_type=%s", cluster_id, env, deploy_type, ) resolved = await resolve_metrics_url( cluster_id=cluster_id, env=env, deploy_type=deploy_type, ) set_metrics_resolution( cluster_id=cluster_id, env=env, deploy_type=deploy_type, url=resolved.url, token=resolved.token, ) logger.info( "metrics_url_resolved_and_cached cluster_id=%s env=%s deploy_type=%s", cluster_id, env, deploy_type, ) return resolved async def get_cluster_info( cluster_id: str, env: str, deploy_type: str, ): """ Call Observability Global Service clusters API and return the first cluster object along with the normalized Auth0 bearer token used for the request. """ request = ResolveMetricsUrlRequest.model_validate( { "cluster_id": cluster_id, "env": env, "deploy_type": deploy_type, } ) auth0_token = await get_server_auth0_token() if not auth0_token: raise AuthServiceError( "Auth0 token is not configured. Complete server-owned auth bootstrap first." ) normalized_auth0_token = normalize_bearer_token(auth0_token) gs_clusters_url = _build_gs_clusters_url() biz_type = resolve_biz_type(deploy_type=request.deploy_type, env=request.env) params = { "bizType": biz_type, "applicationID": request.cluster_id, } headers = { "Accept": "application/json", "Authorization": f"Bearer {normalized_auth0_token}", } logger.info( "cluster_info_resolve_request cluster_id=%s env=%s deploy_type=%s biz_type=%s", request.cluster_id, request.env, request.deploy_type, biz_type, ) try: async with httpx.AsyncClient(timeout=RESOLVE_TIMEOUT) as client: response = await client.get(gs_clusters_url, params=params, headers=headers) if response.status_code == 401: await get_valid_server_auth() refreshed_token = await get_server_auth0_token() if refreshed_token: normalized_auth0_token = normalize_bearer_token(refreshed_token) headers["Authorization"] = f"Bearer {normalized_auth0_token}" response = await client.get(gs_clusters_url, params=params, headers=headers) except (httpx.NetworkError, httpx.ConnectError, httpx.ConnectTimeout) as e: raise NetworkError( "Observability Global Service is unreachable. Check the observability URL and network." ) from e except httpx.TimeoutException as e: raise AuthServiceError( f"Observability Global Service did not respond within {RESOLVE_TIMEOUT} seconds." ) from e if response.status_code >= 400: logger.warning("cluster_info_resolve_http_error status=%s", response.status_code) raise AuthServiceError( f"Observability Global Service error: HTTP {response.status_code}", status_code=response.status_code, ) try: data = response.json() except Exception as e: raise AuthServiceError("Observability Global Service returned invalid JSON.") from e try: if data.get("code") not in (None, 0): raise AuthServiceError( f"Observability Global Service returned error code {data.get('code')}: " f"{data.get('message', '')}" ) clusters = data.get("data", {}).get("clusters", []) if not clusters: raise AuthServiceError( "Observability Global Service returned no clusters for the requested parameters." ) return clusters[0], normalized_auth0_token except AuthServiceError: raise except Exception as e: raise AuthServiceError(f"Observability Global Service response format invalid: {e}") from e async def resolve_metrics_url( cluster_id: str, env: str, deploy_type: str, ) -> ResolveMetricsUrlResponse: """ Resolve the actual metrics URL for a cluster from O11Y GS clusters API. Args: cluster_id: Cluster identifier. env: Environment identifier (e.g. prod/staging/dev). deploy_type: Deploy type used for biz_type mapping. Returns: ResolveMetricsUrlResponse with url and optional token. Raises: AuthServiceError: If auth0 token is unavailable or GS returns an error. NetworkError: If the Global Service is unreachable. """ cluster, normalized_auth0_token = await get_cluster_info( cluster_id=cluster_id, env=env, deploy_type=deploy_type, ) try: read_uri = cluster.get("internalReadUri") or cluster.get("openReadUri") if not read_uri: raise AuthServiceError( "Observability Global Service response missing internalReadUri/openReadUri." ) logger.info("metrics_url_resolve_success cluster_id=%s", cluster.get("id")) return ResolveMetricsUrlResponse(url=read_uri, token=normalized_auth0_token) except AuthServiceError: raise except Exception as e: raise AuthServiceError(f"Observability Global Service response format invalid: {e}") from e def resolve_biz_type(deploy_type: str, env: str = "prod") -> str: base = "" normalized_deploy_type = deploy_type.strip().lower() normalized_env = (env or "prod").strip() if normalized_deploy_type == "dedicated": base = "tidbcloud" elif normalized_deploy_type in {"premium", "resource_pool"}: base = "tidbcloud-nextgen" elif normalized_deploy_type in {"starter", "essential"}: base = "devtier" else: raise AuthServiceError(f"Unknown deploy_type '{deploy_type}'") if not normalized_env: return base if normalized_env == "prod" and base == "tidbcloud": return "tidbcloud" return f"{normalized_env}-{base}" def normalize_bearer_token(token: str) -> str: token_value = token.strip() if token_value.lower().startswith("bearer "): token_value = token_value[7:].strip() return token_value def extract_logical_region(internal_read_uri: str) -> str: logical_region, _ = extract_logical_region_and_provider(internal_read_uri) return logical_region def extract_logical_region_and_provider(internal_read_uri: str) -> tuple[str, str]: if not internal_read_uri or not internal_read_uri.strip(): raise AuthServiceError("Missing internalReadUri in GS response.") try: host = httpx.URL(internal_read_uri).host except Exception as e: raise AuthServiceError(f"Failed to parse internalReadUri: {e}") from e if not host: raise AuthServiceError("internalReadUri host is missing.") match = LOGICAL_REGION_PATTERN.match(host) if not match: raise AuthServiceError( "Unable to parse logical region from internalReadUri host. " "Expected host like " "www.ds...observability.tidbcloud.com." ) logical_region = match.group("logical_region") provider = match.group("provider") if not logical_region: raise AuthServiceError("Logical region is empty in internalReadUri.") if not provider: raise AuthServiceError("Provider is empty in internalReadUri.") return logical_region, provider class O11yDeltaPathResult(BaseModel): slowlog_path: str sql_statement_path: str | None = None async def resolve_slowlog_delta_path( cluster_id: str, env: str, deploy_type: str, ) -> O11yDeltaPathResult: """ Resolve Delta Lake table paths for a cluster using Observability Global Service. Supports cluster internalReadUri hosts from AWS, Alicloud, and GCP domains. Args: cluster_id: Cluster identifier. env: Environment identifier (e.g. prod/staging/dev). deploy_type: Deploy type used for biz_type mapping. Returns: O11yDeltaPathResult with slowlog_path and optional sql_statement_path. sql_statement_path is only returned for premium deploy type. Raises: AuthServiceError: If auth0 token is unavailable or GS returns an error. NetworkError: If the Global Service is unreachable. """ cluster, _ = await get_cluster_info( cluster_id=cluster_id, env=env, deploy_type=deploy_type, ) try: o11y_id = cluster.get("id") tenant_id = cluster.get("tenantID") internal_read_uri = cluster.get("internalReadUri") if not o11y_id: raise AuthServiceError("Observability Global Service response missing cluster id.") if not tenant_id: raise AuthServiceError("Observability Global Service response missing tenantID.") if not internal_read_uri: raise AuthServiceError("Observability Global Service response missing internalReadUri.") logical_region, provider = extract_logical_region_and_provider(internal_read_uri) scheme = "gs" if provider == "gcp" else "s3" bucket_prefix = f"{scheme}://o11y-prod-shared-{logical_region}" # Always return slowlog path slowlog_path = f"{bucket_prefix}/deltalake/{tenant_id}/{o11y_id}/slowlogs" # Return sql_statement path only for premium deploy type sql_statement_path = None normalized_deploy_type = deploy_type.strip().lower() if normalized_deploy_type == "premium": sql_statement_path = ( f"{bucket_prefix}/deltalake/{tenant_id}/{o11y_id}/persisted_statements_summary" ) logger.info("slowlog_path_resolve_success cluster_id=%s", o11y_id) return O11yDeltaPathResult(slowlog_path=slowlog_path, sql_statement_path=sql_statement_path) except AuthServiceError: raise except Exception as e: raise AuthServiceError(f"Observability Global Service response format invalid: {e}") from e