feat: add gateway runtime compatibility checks and minimum version enforcement

This commit is contained in:
Abhimanyu Saharan
2026-02-15 15:59:55 +05:30
parent 0b3fb04630
commit 24731667d4
7 changed files with 479 additions and 0 deletions

View File

@@ -24,3 +24,4 @@ RQ_REDIS_URL=redis://localhost:6379/0
RQ_QUEUE_NAME=default RQ_QUEUE_NAME=default
RQ_DISPATCH_THROTTLE_SECONDS=15.0 RQ_DISPATCH_THROTTLE_SECONDS=15.0
RQ_DISPATCH_MAX_RETRIES=3 RQ_DISPATCH_MAX_RETRIES=3
GATEWAY_MIN_VERSION=2026.02.9

View File

@@ -94,6 +94,7 @@ async def create_gateway(
) -> Gateway: ) -> Gateway:
"""Create a gateway and provision or refresh its main agent.""" """Create a gateway and provision or refresh its main agent."""
service = GatewayAdminLifecycleService(session) service = GatewayAdminLifecycleService(session)
await service.assert_gateway_runtime_compatible(url=payload.url, token=payload.token)
data = payload.model_dump() data = payload.model_dump()
gateway_id = uuid4() gateway_id = uuid4()
data["id"] = gateway_id data["id"] = gateway_id
@@ -133,6 +134,12 @@ async def update_gateway(
organization_id=ctx.organization.id, organization_id=ctx.organization.id,
) )
updates = payload.model_dump(exclude_unset=True) updates = payload.model_dump(exclude_unset=True)
if "url" in updates or "token" in updates:
raw_next_url = updates.get("url", gateway.url)
next_url = raw_next_url.strip() if isinstance(raw_next_url, str) else ""
next_token = updates.get("token", gateway.token)
if next_url:
await service.assert_gateway_runtime_compatible(url=next_url, token=next_token)
await crud.patch(session, gateway, updates) await crud.patch(session, gateway, updates)
await service.ensure_main_agent(gateway, auth, action="update") await service.ensure_main_agent(gateway, auth, action="update")
return gateway return gateway

View File

@@ -61,6 +61,9 @@ class Settings(BaseSettings):
rq_dispatch_retry_base_seconds: float = 10.0 rq_dispatch_retry_base_seconds: float = 10.0
rq_dispatch_retry_max_seconds: float = 120.0 rq_dispatch_retry_max_seconds: float = 120.0
# OpenClaw gateway runtime compatibility
gateway_min_version: str = "2026.02.9"
# Logging # Logging
log_level: str = "INFO" log_level: str = "INFO"
log_format: str = "text" log_format: str = "text"

View File

@@ -26,6 +26,7 @@ from app.services.openclaw.db_agent_state import (
mint_agent_token, mint_agent_token,
) )
from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
@@ -176,6 +177,22 @@ class GatewayAdminLifecycleService(OpenClawDBService):
return True return True
return True return True
async def assert_gateway_runtime_compatible(self, *, url: str, token: str | None) -> None:
"""Validate that a gateway runtime meets minimum supported version."""
config = GatewayClientConfig(url=url, token=token)
try:
result = await check_gateway_runtime_compatibility(config)
except OpenClawGatewayError as exc:
raise HTTPException(
status_code=status.HTTP_502_BAD_GATEWAY,
detail=f"Gateway compatibility check failed: {exc}",
) from exc
if not result.compatible:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=result.message or "Gateway runtime version is not supported.",
)
async def provision_main_agent_record( async def provision_main_agent_record(
self, self,
gateway: Gateway, gateway: Gateway,

View File

@@ -0,0 +1,221 @@
"""Gateway runtime version compatibility checks."""
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import Any
from app.core.config import settings
from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError, openclaw_call
_VERSION_PATTERN = re.compile(r"(?i)v?(?P<version>\d+(?:\.\d+)+)")
_PRIMARY_VERSION_PATHS: tuple[tuple[str, ...], ...] = (
("version",),
("gatewayVersion",),
("appVersion",),
("buildVersion",),
("gateway", "version"),
("app", "version"),
("server", "version"),
("runtime", "version"),
("meta", "version"),
("build", "version"),
("info", "version"),
)
@dataclass(frozen=True, slots=True)
class GatewayVersionCheckResult:
"""Compatibility verdict for a gateway runtime version."""
compatible: bool
minimum_version: str
current_version: str | None
message: str | None = None
def _normalized_minimum_version() -> str:
raw = (settings.gateway_min_version or "").strip()
return raw or "2026.1.30"
def _parse_version_parts(value: str) -> tuple[int, ...] | None:
match = _VERSION_PATTERN.search(value.strip())
if match is None:
return None
numeric = match.group("version")
return tuple(int(part) for part in numeric.split("."))
def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int:
width = max(len(left), len(right))
left_padded = left + (0,) * (width - len(left))
right_padded = right + (0,) * (width - len(right))
if left_padded < right_padded:
return -1
if left_padded > right_padded:
return 1
return 0
def _value_at_path(payload: object, path: tuple[str, ...]) -> object | None:
current = payload
for segment in path:
if not isinstance(current, dict):
return None
if segment not in current:
return None
current = current[segment]
return current
def _coerce_version_string(value: object) -> str | None:
if isinstance(value, str):
normalized = value.strip()
return normalized or None
if isinstance(value, (int, float)):
return str(value)
return None
def _iter_fallback_version_values(payload: object) -> list[str]:
if not isinstance(payload, dict):
return []
stack: list[dict[str, Any]] = [payload]
discovered: list[str] = []
while stack:
node = stack.pop()
for key, value in node.items():
if isinstance(value, dict):
stack.append(value)
key_lower = key.lower()
if "version" not in key_lower or "protocol" in key_lower:
continue
candidate = _coerce_version_string(value)
if candidate is not None:
discovered.append(candidate)
return discovered
def extract_gateway_version(payload: object) -> str | None:
"""Extract a gateway runtime version string from status/health payloads."""
for path in _PRIMARY_VERSION_PATHS:
candidate = _coerce_version_string(_value_at_path(payload, path))
if candidate is not None:
return candidate
for candidate in _iter_fallback_version_values(payload):
if _parse_version_parts(candidate) is not None:
return candidate
return None
def evaluate_gateway_version(
*,
current_version: str | None,
minimum_version: str | None = None,
) -> GatewayVersionCheckResult:
"""Return compatibility result for the reported gateway version."""
min_version = (minimum_version or _normalized_minimum_version()).strip()
min_parts = _parse_version_parts(min_version)
if min_parts is None:
msg = (
"Server configuration error: GATEWAY_MIN_VERSION is invalid. "
f"Expected a dotted numeric version, got '{min_version}'."
)
return GatewayVersionCheckResult(
compatible=False,
minimum_version=min_version,
current_version=current_version,
message=msg,
)
if current_version is None:
return GatewayVersionCheckResult(
compatible=False,
minimum_version=min_version,
current_version=None,
message=(
"Unable to determine gateway version from runtime metadata. "
f"Minimum supported version is {min_version}."
),
)
current_parts = _parse_version_parts(current_version)
if current_parts is None:
return GatewayVersionCheckResult(
compatible=False,
minimum_version=min_version,
current_version=current_version,
message=(
f"Gateway reported an unsupported version format '{current_version}'. "
f"Minimum supported version is {min_version}."
),
)
if _compare_versions(current_parts, min_parts) < 0:
return GatewayVersionCheckResult(
compatible=False,
minimum_version=min_version,
current_version=current_version,
message=(
f"Gateway version {current_version} is not supported. "
f"Minimum supported version is {min_version}."
),
)
return GatewayVersionCheckResult(
compatible=True,
minimum_version=min_version,
current_version=current_version,
)
async def _fetch_runtime_metadata(config: GatewayConfig) -> object:
last_error: OpenClawGatewayError | None = None
for method in ("status", "health"):
try:
return await openclaw_call(method, config=config)
except OpenClawGatewayError as exc:
last_error = exc
continue
if last_error is not None:
raise last_error
return {}
async def _fetch_schema_metadata(config: GatewayConfig) -> object | None:
try:
return await openclaw_call("config.schema", config=config)
except OpenClawGatewayError:
return None
async def check_gateway_runtime_compatibility(
config: GatewayConfig,
*,
minimum_version: str | None = None,
) -> GatewayVersionCheckResult:
"""Fetch runtime metadata and evaluate gateway version compatibility."""
schema_payload = await _fetch_schema_metadata(config)
current_version = extract_gateway_version(schema_payload)
if current_version is not None:
return evaluate_gateway_version(
current_version=current_version,
minimum_version=minimum_version,
)
payload = await _fetch_runtime_metadata(config)
current_version = extract_gateway_version(payload)
if current_version is None:
try:
health_payload = await openclaw_call("health", config=config)
except OpenClawGatewayError:
health_payload = None
if health_payload is not None:
current_version = extract_gateway_version(health_payload)
return evaluate_gateway_version(
current_version=current_version,
minimum_version=minimum_version,
)

View File

@@ -20,6 +20,7 @@ from app.schemas.gateway_api import (
GatewaysStatusResponse, GatewaysStatusResponse,
) )
from app.services.openclaw.db_service import OpenClawDBService from app.services.openclaw.db_service import OpenClawDBService
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board from app.services.openclaw.gateway_resolver import gateway_client_config, require_gateway_for_board
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
from app.services.openclaw.gateway_rpc import ( from app.services.openclaw.gateway_rpc import (
@@ -188,6 +189,20 @@ class GatewaySessionService(OpenClawDBService):
) -> GatewaysStatusResponse: ) -> GatewaysStatusResponse:
board, config, main_session = await self.resolve_gateway(params, user=user) board, config, main_session = await self.resolve_gateway(params, user=user)
self._require_same_org(board, organization_id) self._require_same_org(board, organization_id)
try:
compatibility = await check_gateway_runtime_compatibility(config)
except OpenClawGatewayError as exc:
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
error=str(exc),
)
if not compatibility.compatible:
return GatewaysStatusResponse(
connected=False,
gateway_url=config.url,
error=compatibility.message,
)
try: try:
sessions = await openclaw_call("sessions.list", config=config) sessions = await openclaw_call("sessions.list", config=config)
if isinstance(sessions, dict): if isinstance(sessions, dict):

View File

@@ -0,0 +1,215 @@
# ruff: noqa: S101
from __future__ import annotations
from uuid import uuid4
import pytest
from fastapi import HTTPException
import app.services.openclaw.admin_service as admin_service
import app.services.openclaw.gateway_compat as gateway_compat
import app.services.openclaw.session_service as session_service
from app.schemas.gateway_api import GatewayResolveQuery
from app.services.openclaw.admin_service import GatewayAdminLifecycleService
from app.services.openclaw.gateway_compat import GatewayVersionCheckResult
from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayError
from app.services.openclaw.session_service import GatewaySessionService
def test_extract_gateway_version_prefers_primary_path() -> None:
payload = {
"gateway": {"version": "2026.2.1"},
"protocolVersion": 3,
"meta": {"version": "2026.1.30"},
}
assert gateway_compat.extract_gateway_version(payload) == "2026.2.1"
def test_evaluate_gateway_version_detects_old_runtime() -> None:
result = gateway_compat.evaluate_gateway_version(
current_version="2025.12.1",
minimum_version="2026.1.30",
)
assert result.compatible is False
assert result.minimum_version == "2026.1.30"
assert "Minimum supported version is 2026.1.30" in (result.message or "")
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_prefers_schema_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
return {"version": "2026.2.13"}
raise AssertionError(f"unexpected method: {method}")
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema"]
assert result.compatible is True
assert result.current_version == "2026.2.13"
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_falls_back_to_health(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
raise OpenClawGatewayError("unknown method")
if method == "status":
raise OpenClawGatewayError("unknown method")
return {"version": "2026.2.0"}
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema", "status", "health"]
assert result.compatible is True
assert result.current_version == "2026.2.0"
@pytest.mark.asyncio
async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_no_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
calls: list[str] = []
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
calls.append(method)
if method == "config.schema":
return {"schema": {"title": "Gateway schema"}}
if method == "status":
return {"uptime": 1234}
return {"version": "2026.2.0"}
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
result = await gateway_compat.check_gateway_runtime_compatibility(
GatewayConfig(url="ws://gateway.example/ws"),
minimum_version="2026.1.30",
)
assert calls == ["config.schema", "status", "health"]
assert result.compatible is True
assert result.current_version == "2026.2.0"
@pytest.mark.asyncio
async def test_admin_service_rejects_incompatible_gateway(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
return GatewayVersionCheckResult(
compatible=False,
minimum_version="2026.1.30",
current_version="2026.1.0",
message="Gateway version 2026.1.0 is not supported.",
)
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
assert exc_info.value.status_code == 422
assert "not supported" in str(exc_info.value.detail)
@pytest.mark.asyncio
async def test_admin_service_maps_gateway_transport_errors(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
raise OpenClawGatewayError("connection refused")
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
with pytest.raises(HTTPException) as exc_info:
await service.assert_gateway_runtime_compatible(url="ws://gateway.example/ws", token=None)
assert exc_info.value.status_code == 502
assert "compatibility check failed" in str(exc_info.value.detail).lower()
@pytest.mark.asyncio
async def test_gateway_status_reports_incompatible_version(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
return GatewayVersionCheckResult(
compatible=False,
minimum_version="2026.1.30",
current_version="2026.1.0",
message="Gateway version 2026.1.0 is not supported.",
)
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
response = await service.get_status(
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
organization_id=uuid4(),
user=None,
)
assert response.connected is False
assert response.error == "Gateway version 2026.1.0 is not supported."
@pytest.mark.asyncio
async def test_gateway_status_returns_sessions_when_version_compatible(
monkeypatch: pytest.MonkeyPatch,
) -> None:
async def _fake_check(config: GatewayConfig, *, minimum_version: str | None = None) -> object:
_ = (config, minimum_version)
return GatewayVersionCheckResult(
compatible=True,
minimum_version="2026.1.30",
current_version="2026.2.0",
message=None,
)
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
_ = (params, config)
assert method == "sessions.list"
return {"sessions": [{"key": "agent:main"}]}
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call)
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
response = await service.get_status(
params=GatewayResolveQuery(gateway_url="ws://gateway.example/ws"),
organization_id=uuid4(),
user=None,
)
assert response.connected is True
assert response.sessions_count == 1