from __future__ import annotations import ipaddress import json import os from collections.abc import AsyncIterator, Mapping from contextlib import asynccontextmanager from pathlib import Path from typing import Any from urllib.parse import urlparse import httpx from starlette.applications import Starlette from starlette.requests import Request from starlette.responses import JSONResponse, Response from starlette.routing import Route from packages.hub_runtime import read_secret_file from .common import connector_context, load_connector_identity_key, run_asgi UPSTREAM_TO_PUBLIC = { "gitea.actions.runners.status": "server.ci.runners.status", "gitea.projects.catalog": "server.project.list", "gitea.projects.reconcile": "server.project.reconcile", "get_system_summary": "server.summary.get", "list_apps": "server.application.list", "list_containers": "server.container.list", "get_safety_findings": "server.safety.findings", "get_action_registry": "server.action.list", "preview_action": "server.operation.preview", "search_audit": "server.audit.search", "query_knowledge": "server.knowledge.query", "server.operation.request": "server.operation.request", "server.operation.status": "server.operation.status", "server.operation.execute": "server.operation.execute", "server.job.status": "server.job.status", "server.job.cancel": "server.job.cancel", "deployment.preflight": "server.deployment.preflight", "deployment.capabilities": "server.deployment.capabilities", "enrollment.preflight": "server.deployment.enrollment.preflight", "target.enroll": "server.deployment.target.enroll", "enrollment.status": "server.deployment.enrollment.status", "deployment.apply": "server.deployment.apply", "deployment.status": "server.deployment.status", "deployment.verify": "server.deployment.verify", "deployment.rollback": "server.deployment.rollback", "appops.deployment.status": "server.appops.deployment.status", "appops.self_upgrade.preflight": "server.appops.self_upgrade.preflight", "appops.self_upgrade.apply": "server.appops.self_upgrade.apply", "appops.self_upgrade.status": "server.appops.self_upgrade.status", } PUBLIC_TO_UPSTREAM = {value: key for key, value in UPSTREAM_TO_PUBLIC.items()} MAX_BODY_BYTES = 1024 * 1024 MAX_RESPONSE_BYTES = 2 * 1024 * 1024 def upstream_headers( request_headers: Mapping[str, str], token: str, *, has_body: bool ) -> dict[str, str]: headers = { "Authorization": f"Bearer {token}", "Accept": "application/json, text/event-stream", } if has_body: headers["Content-Type"] = request_headers.get("content-type", "application/json") for name in ("mcp-session-id", "mcp-protocol-version", "last-event-id"): if value := request_headers.get(name): headers[name] = value return headers def _validate_endpoint(value: str) -> str: endpoint = value.rstrip("/") parsed = urlparse(endpoint) if ( parsed.scheme not in {"http", "https"} or not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment or parsed.path not in {"", "/"} ): raise ValueError("AppOps endpoint must be an HTTP(S) origin") if parsed.scheme == "http": try: address = ipaddress.ip_address(parsed.hostname) except ValueError as exc: raise ValueError("plain HTTP AppOps endpoints must use a private IP address") from exc if not address.is_private: raise ValueError("plain HTTP AppOps endpoints must use a private IP address") return endpoint def _settings() -> tuple[str, str, set[str], bytes]: endpoint = _validate_endpoint(os.environ["APPOPS_ENDPOINT"]) token = read_secret_file( Path(os.environ.get("APPOPS_SERVICE_TOKEN_FILE", "/run/secrets/appops_hub_service_token")) ) if len(token) < 32: raise ValueError("AppOps service token must contain at least 32 characters") projects = { item.strip().lower() for item in os.environ.get("APPOPS_PROJECTS", "server-ops").split(",") if item.strip() } return endpoint, token, projects, load_connector_identity_key() def rewrite_request(document: Any) -> Any: if not isinstance(document, dict) or document.get("method") != "tools/call": return document params = document.get("params") if not isinstance(params, dict) or not isinstance(params.get("name"), str): return document upstream_name = PUBLIC_TO_UPSTREAM.get(params["name"]) if upstream_name is None: raise ValueError("AppOps tool is not registered") return {**document, "params": {**params, "name": upstream_name}} def rewrite_response(document: Any, request_method: str | None) -> Any: if request_method != "tools/list" or not isinstance(document, dict): return document result = document.get("result") if not isinstance(result, dict) or not isinstance(result.get("tools"), list): return document tools = [] for tool in result["tools"]: if not isinstance(tool, dict) or not isinstance(tool.get("name"), str): continue if public_name := UPSTREAM_TO_PUBLIC.get(tool["name"]): tools.append({**tool, "name": public_name}) return {**document, "result": {**result, "tools": tools}} def build_application() -> Starlette: endpoint, token, projects, signing_key = _settings() client = httpx.AsyncClient(timeout=httpx.Timeout(60.0, connect=5.0), follow_redirects=False) async def health(_: Request) -> JSONResponse: return JSONResponse({"status": "healthy", "component": "appops-connector"}) async def ready(_: Request) -> JSONResponse: try: response = await client.get(f"{endpoint}/api/health") payload = response.json() ready_state = response.status_code == 200 and isinstance(payload, dict) except (httpx.HTTPError, ValueError, TypeError): ready_state, payload = False, {} return JSONResponse( { "status": "ready" if ready_state else "unavailable", "component": "appops-connector", "upstream": payload, }, status_code=200 if ready_state else 503, ) async def mcp(request: Request) -> Response: body = await request.body() if len(body) > MAX_BODY_BYTES: return JSONResponse({"error": "APPOPS_REQUEST_TOO_LARGE"}, status_code=413) document: Any = None method: str | None = None if body: try: document = json.loads(body) if not isinstance(document, dict): raise ValueError("JSON-RPC batches are not accepted") method = document.get("method") if method == "tools/call": connector_context( {str(key).lower(): str(value) for key, value in request.headers.items()}, projects, signing_key=signing_key, ) document = rewrite_request(document) body = json.dumps(document, separators=(",", ":")).encode("utf-8") except PermissionError: return JSONResponse({"error": "APPOPS_CONNECTOR_IDENTITY_DENIED"}, status_code=403) except (ValueError, TypeError, json.JSONDecodeError): return JSONResponse({"error": "APPOPS_REQUEST_INVALID"}, status_code=400) headers = upstream_headers(request.headers, token, has_body=bool(body)) try: upstream = await client.request( request.method, f"{endpoint}/mcp/", content=body or None, headers=headers, ) except httpx.HTTPError: return JSONResponse({"error": "APPOPS_UNAVAILABLE"}, status_code=502) content = upstream.content if len(content) > MAX_RESPONSE_BYTES: return JSONResponse({"error": "APPOPS_RESPONSE_TOO_LARGE"}, status_code=502) content_type = upstream.headers.get("content-type", "application/json") if content and "application/json" in content_type: try: content = json.dumps( rewrite_response(upstream.json(), method), separators=(",", ":") ).encode("utf-8") except (ValueError, TypeError): return JSONResponse({"error": "APPOPS_RESPONSE_INVALID"}, status_code=502) response_headers = {"content-type": content_type} if session_id := upstream.headers.get("mcp-session-id"): response_headers["mcp-session-id"] = session_id return Response(content, status_code=upstream.status_code, headers=response_headers) @asynccontextmanager async def lifespan(_: Starlette) -> AsyncIterator[None]: yield await client.aclose() return Starlette( routes=[ Route("/health", health), Route("/healthz", health), Route("/ready", ready), Route("/health/ready", ready), Route("/mcp", mcp, methods=["GET", "POST", "DELETE"]), ], lifespan=lifespan, ) def run() -> None: run_asgi(build_application()) if __name__ == "__main__": run()