feat: add gateway runtime compatibility checks and minimum version enforcement
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
221
backend/app/services/openclaw/gateway_compat.py
Normal file
221
backend/app/services/openclaw/gateway_compat.py
Normal 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,
|
||||||
|
)
|
||||||
@@ -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):
|
||||||
|
|||||||
215
backend/tests/test_gateway_version_compat.py
Normal file
215
backend/tests/test_gateway_version_compat.py
Normal 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
|
||||||
Reference in New Issue
Block a user