from __future__ import annotations import fnmatch import json import os import time from pathlib import Path from typing import Any import yaml # type: ignore[import-untyped] from services.control_api.contextforge import ContextForgeClient, ContextForgeSettings from services.control_api.contextforge.models import VirtualServerDesiredState from services.runtime.common import deterministic_id PROJECT_SLUG = "gitea-all" ENVIRONMENT = "prod" GATEWAY_SLUG = "itworx-gitea" SERVER_NAME = "gitea-all-prod-readonly" REQUIRED_RECOVERY_TOOL = "gitea.pullrequest.sync" RECONCILE_ATTEMPTS = 4 RECONCILE_DELAY_SECONDS = 2.0 def _entities(value: Any, key: str) -> list[dict[str, Any]]: if isinstance(value, list): return [item for item in value if isinstance(item, dict)] if isinstance(value, dict) and isinstance(value.get(key), list): return [item for item in value[key] if isinstance(item, dict)] return [] def _published_tool_name(item: dict[str, Any]) -> str: # ContextForge clients invoke customName when present. Prefer the actual # published name over originalName so the canary validates the same surface # ChatGPT sees rather than the gateway's import-time identifier. for key in ("customName", "custom_name", "name", "originalName", "original_name"): value = item.get(key) if isinstance(value, str) and value: return value return "" def _published_prompt_name(item: dict[str, Any]) -> str: for key in ("customName", "custom_name", "name", "originalName", "original_name"): value = item.get(key) if isinstance(value, str) and value: return value return "" def _load_surface(catalog_root: Path) -> tuple[list[str], list[str]]: document = yaml.safe_load( (catalog_root / "projects" / f"{PROJECT_SLUG}.yaml").read_text(encoding="utf-8") ) if not isinstance(document, dict) or not isinstance(document.get("spec"), dict): raise RuntimeError("gitea-all catalog document is invalid") servers = document["spec"].get("virtualServers", []) server = next( ( item for item in servers if isinstance(item, dict) and item.get("slug") == SERVER_NAME and item.get("environment") == ENVIRONMENT ), None, ) if not isinstance(server, dict): raise RuntimeError("gitea-all production virtual server is missing") tools = server.get("toolAllowPatterns", []) prompts = server.get("promptAllowPatterns", []) if not isinstance(tools, list) or not all(isinstance(item, str) for item in tools): raise RuntimeError("gitea-all tool surface is invalid") if not isinstance(prompts, list) or not all(isinstance(item, str) for item in prompts): raise RuntimeError("gitea-all prompt surface is invalid") if any(any(token in item for token in ("*", "?", "[")) for item in tools): raise RuntimeError("gitea-all post-deploy surface must remain explicit") if REQUIRED_RECOVERY_TOOL not in tools: raise RuntimeError("gitea pull-request recovery tool is not published") return list(tools), list(prompts) def _global_tools(client: ContextForgeClient) -> list[dict[str, Any]]: value = client._request( # noqa: SLF001 - fixed ContextForge inventory endpoint "GET", "/tools", params={"limit": 0, "include_pagination": "false"}, retryable=True, ) return _entities(value, "tools") def _global_prompts(client: ContextForgeClient) -> list[dict[str, Any]]: value = client._request( # noqa: SLF001 - fixed ContextForge inventory endpoint "GET", "/prompts", params={"limit": 0, "include_pagination": "false"}, retryable=True, ) return _entities(value, "prompts") def _select_tool_ids(tools: list[dict[str, Any]], names: list[str]) -> tuple[str, ...]: by_name: dict[str, set[str]] = {} for item in tools: external_id = item.get("id") name = _published_tool_name(item) if not isinstance(external_id, str) or not external_id or not name: continue by_name.setdefault(name, set()).add(external_id) missing = sorted(set(names) - by_name.keys()) if missing: raise RuntimeError( "ContextForge global inventory is missing required published tools: " + ", ".join(missing[:8]) ) ambiguous = sorted(name for name in names if len(by_name.get(name, set())) != 1) if ambiguous: raise RuntimeError( "ContextForge global inventory has ambiguous published tools: " + ", ".join(ambiguous[:8]) ) return tuple(sorted(next(iter(by_name[name])) for name in names)) def _select_prompt_ids(prompts: list[dict[str, Any]], patterns: list[str]) -> tuple[str, ...]: selected: set[str] = set() for item in prompts: external_id = item.get("id") name = _published_prompt_name(item) if not isinstance(external_id, str) or not external_id or not name: continue if any(fnmatch.fnmatchcase(name, pattern) for pattern in patterns): selected.add(external_id) return tuple(sorted(selected)) def _live_server_tools(client: ContextForgeClient, external_id: str) -> list[dict[str, Any]]: value = client._request( # noqa: SLF001 - fixed managed virtual-server verifier "GET", f"/servers/{external_id}/tools", params={"include_pagination": "false"}, retryable=True, ) return _entities(value, "tools") def _desired_state( client: ContextForgeClient, catalog_root: Path, ) -> tuple[VirtualServerDesiredState, set[str]]: required_tools, prompt_patterns = _load_surface(catalog_root) gateway = client._find(GATEWAY_SLUG) # noqa: SLF001 - fixed managed gateway only if gateway is None: raise RuntimeError("managed Gitea ContextForge gateway is missing") # Canonicalize the managed Gitea gateway first. Then select the complete # virtual-server surface from ContextForge's global registry so tools from # other managed gateways (for example project.status.get) are preserved. client.ensure_canonical_tool_names(client.list_gateway_tools(gateway)) tools = _global_tools(client) try: selected_tool_ids = _select_tool_ids(tools, required_tools) except RuntimeError: # One bounded refresh repairs stale Gitea discovery without repeatedly # mutating ContextForge on a deterministic catalog error. client.refresh_gateway(gateway) client.ensure_canonical_tool_names(client.list_gateway_tools(gateway)) tools = _global_tools(client) selected_tool_ids = _select_tool_ids(tools, required_tools) selected_prompt_ids = _select_prompt_ids(_global_prompts(client), prompt_patterns) desired = VirtualServerDesiredState( external_id=deterministic_id("virtual-server", f"{PROJECT_SLUG}/{ENVIRONMENT}"), name=SERVER_NAME, description="ITWorx managed project and environment scoped MCP server", associated_tool_ids=selected_tool_ids, associated_prompt_ids=selected_prompt_ids, ) return desired, set(required_tools) def _reconcile_once( client: ContextForgeClient, catalog_root: Path, ) -> dict[str, object]: desired, required_tools = _desired_state(client, catalog_root) server = client.ensure_virtual_server(desired) # ContextForge v1.0.6 may return camelCase association fields. Re-apply the # exact desired state and verify the server-facing tools endpoint rather than # trusting the shape of a successful update response. client._request( # noqa: SLF001 - fixed server, exact catalog-derived associations "PUT", f"/servers/{server.external_id}", json_body={ "name": desired.name, "description": desired.description, "associated_tools": list(desired.associated_tool_ids), "associated_prompts": list(desired.associated_prompt_ids), "visibility": desired.visibility, }, ) live_tools = _live_server_tools(client, server.external_id) live_ids = tuple(sorted(str(item.get("id", "")) for item in live_tools if item.get("id"))) live_names = {_published_tool_name(item) for item in live_tools if _published_tool_name(item)} if live_ids != desired.associated_tool_ids: raise RuntimeError("ContextForge virtual-server tool association read-back mismatch") missing_live = sorted(required_tools - live_names) if missing_live: raise RuntimeError( "ContextForge virtual server is missing required published tools: " + ", ".join(missing_live[:8]) ) if REQUIRED_RECOVERY_TOOL not in live_names: raise RuntimeError("Gitea pull-request recovery tool is not live") return { "status": "pass", "server": SERVER_NAME, "toolCount": len(live_ids), "promptCount": len(desired.associated_prompt_ids), "pullRequestSyncLive": True, } def reconcile_gitea_public_surface( client: ContextForgeClient, catalog_root: Path, *, attempts: int = RECONCILE_ATTEMPTS, delay_seconds: float = RECONCILE_DELAY_SECONDS, ) -> dict[str, object]: if not 1 <= attempts <= 6: raise ValueError("reconcile attempts must be between 1 and 6") last_error: RuntimeError | None = None for attempt in range(attempts): try: return _reconcile_once(client, catalog_root) except RuntimeError as exc: last_error = exc if attempt + 1 < attempts: time.sleep(delay_seconds) assert last_error is not None raise last_error def main() -> None: catalog_root = Path(os.environ.get("HUB_CATALOG_ROOT", "/opt/hub/catalog")).resolve() client = ContextForgeClient(ContextForgeSettings.from_env()) try: result = reconcile_gitea_public_surface(client, catalog_root) finally: client.close() print(json.dumps(result, sort_keys=True, separators=(",", ":"))) if __name__ == "__main__": main()