263 lines
9.9 KiB
Python
263 lines
9.9 KiB
Python
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()
|