refactor: rename compatibility check function and update version extraction logic #159
This commit is contained in:
@@ -28,7 +28,7 @@ from app.services.openclaw.db_agent_state import (
|
||||
)
|
||||
from app.services.openclaw.db_service import OpenClawDBService
|
||||
from app.services.openclaw.error_messages import normalize_gateway_error_message
|
||||
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
|
||||
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
|
||||
from app.services.openclaw.gateway_rpc import GatewayConfig as GatewayClientConfig
|
||||
from app.services.openclaw.gateway_rpc import OpenClawGatewayError, openclaw_call
|
||||
from app.services.openclaw.provisioning import OpenClawGatewayProvisioner
|
||||
@@ -200,7 +200,7 @@ class GatewayAdminLifecycleService(OpenClawDBService):
|
||||
disable_device_pairing=disable_device_pairing,
|
||||
)
|
||||
try:
|
||||
result = await check_gateway_runtime_compatibility(config)
|
||||
result = await check_gateway_version_compatibility(config)
|
||||
except OpenClawGatewayError as exc:
|
||||
detail = normalize_gateway_error_message(str(exc))
|
||||
raise HTTPException(
|
||||
|
||||
@@ -4,30 +4,18 @@ 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,
|
||||
openclaw_connect_metadata,
|
||||
)
|
||||
|
||||
_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"),
|
||||
_CALVER_PATTERN = re.compile(
|
||||
r"^v?(?P<year>\d{4})\.(?P<month>\d{1,2})\.(?P<day>\d{1,2})(?:-(?P<rev>\d+))?$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_CONNECT_VERSION_PATH: tuple[str, ...] = ("server", "version")
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
@@ -46,11 +34,18 @@ def _normalized_minimum_version() -> str:
|
||||
|
||||
|
||||
def _parse_version_parts(value: str) -> tuple[int, ...] | None:
|
||||
match = _VERSION_PATTERN.search(value.strip())
|
||||
match = _CALVER_PATTERN.match(value.strip())
|
||||
if match is None:
|
||||
return None
|
||||
numeric = match.group("version")
|
||||
return tuple(int(part) for part in numeric.split("."))
|
||||
year = int(match.group("year"))
|
||||
month = int(match.group("month"))
|
||||
day = int(match.group("day"))
|
||||
revision = int(match.group("rev") or 0)
|
||||
if month < 1 or month > 12:
|
||||
return None
|
||||
if day < 1 or day > 31:
|
||||
return None
|
||||
return (year, month, day, revision)
|
||||
|
||||
|
||||
def _compare_versions(left: tuple[int, ...], right: tuple[int, ...]) -> int:
|
||||
@@ -84,36 +79,9 @@ def _coerce_version_string(value: object) -> str | None:
|
||||
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 extract_connect_server_version(payload: object) -> str | None:
|
||||
"""Extract the canonical runtime version from connect metadata."""
|
||||
return _coerce_version_string(_value_at_path(payload, _CONNECT_VERSION_PATH))
|
||||
|
||||
|
||||
def evaluate_gateway_version(
|
||||
@@ -127,7 +95,7 @@ def evaluate_gateway_version(
|
||||
if min_parts is None:
|
||||
msg = (
|
||||
"Server configuration error: GATEWAY_MIN_VERSION is invalid. "
|
||||
f"Expected a dotted numeric version, got '{min_version}'."
|
||||
f"Expected CalVer 'YYYY.M.D' or 'YYYY.M.D-REV', got '{min_version}'."
|
||||
)
|
||||
return GatewayVersionCheckResult(
|
||||
compatible=False,
|
||||
@@ -177,64 +145,14 @@ def evaluate_gateway_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 _fetch_connect_metadata(config: GatewayConfig) -> object | None:
|
||||
try:
|
||||
return await openclaw_connect_metadata(config=config)
|
||||
except OpenClawGatewayError:
|
||||
return None
|
||||
|
||||
|
||||
async def check_gateway_runtime_compatibility(
|
||||
async def check_gateway_version_compatibility(
|
||||
config: GatewayConfig,
|
||||
*,
|
||||
minimum_version: str | None = None,
|
||||
) -> GatewayVersionCheckResult:
|
||||
"""Fetch runtime metadata and evaluate gateway version compatibility."""
|
||||
connect_payload = await _fetch_connect_metadata(config)
|
||||
current_version = extract_gateway_version(connect_payload)
|
||||
if current_version is not None:
|
||||
return evaluate_gateway_version(
|
||||
current_version=current_version,
|
||||
minimum_version=minimum_version,
|
||||
)
|
||||
|
||||
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)
|
||||
"""Use connect metadata server.version and evaluate compatibility."""
|
||||
connect_payload = await openclaw_connect_metadata(config=config)
|
||||
current_version = extract_connect_server_version(connect_payload)
|
||||
return evaluate_gateway_version(
|
||||
current_version=current_version,
|
||||
minimum_version=minimum_version,
|
||||
|
||||
@@ -21,7 +21,7 @@ from app.schemas.gateway_api import (
|
||||
)
|
||||
from app.services.openclaw.db_service import OpenClawDBService
|
||||
from app.services.openclaw.error_messages import normalize_gateway_error_message
|
||||
from app.services.openclaw.gateway_compat import check_gateway_runtime_compatibility
|
||||
from app.services.openclaw.gateway_compat import check_gateway_version_compatibility
|
||||
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 (
|
||||
@@ -197,7 +197,7 @@ class GatewaySessionService(OpenClawDBService):
|
||||
board, config, main_session = await self.resolve_gateway(params, user=user)
|
||||
self._require_same_org(board, organization_id)
|
||||
try:
|
||||
compatibility = await check_gateway_runtime_compatibility(config)
|
||||
compatibility = await check_gateway_version_compatibility(config)
|
||||
except OpenClawGatewayError as exc:
|
||||
return GatewaysStatusResponse(
|
||||
connected=False,
|
||||
|
||||
@@ -16,147 +16,153 @@ from app.services.openclaw.gateway_rpc import GatewayConfig, OpenClawGatewayErro
|
||||
from app.services.openclaw.session_service import GatewaySessionService
|
||||
|
||||
|
||||
def test_extract_gateway_version_prefers_primary_path() -> None:
|
||||
def test_extract_connect_server_version_uses_server_version_as_source_of_truth() -> None:
|
||||
payload = {
|
||||
"gateway": {"version": "2026.2.1"},
|
||||
"protocolVersion": 3,
|
||||
"meta": {"version": "2026.1.30"},
|
||||
"version": "dev",
|
||||
"runtime": {"version": "2026.1.0"},
|
||||
"server": {"version": "2026.2.21-2"},
|
||||
}
|
||||
|
||||
assert gateway_compat.extract_gateway_version(payload) == "2026.2.1"
|
||||
assert gateway_compat.extract_connect_server_version(payload) == "2026.2.21-2"
|
||||
|
||||
|
||||
def test_evaluate_gateway_version_detects_old_runtime() -> None:
|
||||
def test_extract_connect_server_version_returns_none_when_server_version_missing() -> None:
|
||||
payload = {
|
||||
"version": "2026.2.21-2",
|
||||
"runtime": {"version": "2026.2.21-2"},
|
||||
}
|
||||
|
||||
assert gateway_compat.extract_connect_server_version(payload) is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("current_version", "minimum_version", "expected_compatible"),
|
||||
[
|
||||
("2026.2.21", "2026.2.21", True),
|
||||
("2026.02.20", "2026.2.20", True),
|
||||
("2026.2.22", "2026.2.21", True),
|
||||
("2026.2.21-2", "2026.2.21-1", True),
|
||||
("2026.2.21-1", "2026.2.21-2", False),
|
||||
("2026.2.20", "2026.2.21", False),
|
||||
],
|
||||
)
|
||||
def test_evaluate_gateway_version_compares_calver(
|
||||
*,
|
||||
current_version: str,
|
||||
minimum_version: str,
|
||||
expected_compatible: bool,
|
||||
) -> None:
|
||||
result = gateway_compat.evaluate_gateway_version(
|
||||
current_version="2025.12.1",
|
||||
current_version=current_version,
|
||||
minimum_version=minimum_version,
|
||||
)
|
||||
|
||||
assert result.compatible is expected_compatible
|
||||
assert result.current_version == current_version
|
||||
assert result.minimum_version == minimum_version
|
||||
|
||||
|
||||
@pytest.mark.parametrize("invalid_current", ["dev", "latest", "2026.13.1", "2026.2.0-beta"])
|
||||
def test_evaluate_gateway_version_rejects_non_calver_current(invalid_current: str) -> None:
|
||||
result = gateway_compat.evaluate_gateway_version(
|
||||
current_version=invalid_current,
|
||||
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 "")
|
||||
assert result.current_version == invalid_current
|
||||
assert "unsupported version format" in (result.message or "").lower()
|
||||
|
||||
|
||||
def test_evaluate_gateway_version_rejects_non_calver_minimum_version() -> None:
|
||||
result = gateway_compat.evaluate_gateway_version(
|
||||
current_version="2026.2.21",
|
||||
minimum_version="1.2.3",
|
||||
)
|
||||
|
||||
assert result.compatible is False
|
||||
assert result.minimum_version == "1.2.3"
|
||||
assert "expected calver" in (result.message or "").lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_gateway_runtime_compatibility_prefers_schema_version(
|
||||
async def test_check_gateway_version_compatibility_uses_connect_server_version_only(
|
||||
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}")
|
||||
|
||||
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
||||
_ = config
|
||||
return None
|
||||
return {
|
||||
"version": "dev",
|
||||
"runtime": {"version": "2026.1.0"},
|
||||
"server": {"version": "2026.2.13"},
|
||||
}
|
||||
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
||||
|
||||
result = await gateway_compat.check_gateway_runtime_compatibility(
|
||||
result = await gateway_compat.check_gateway_version_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_prefers_connect_metadata(
|
||||
async def test_check_gateway_version_compatibility_fails_without_server_version(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
|
||||
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
||||
_ = config
|
||||
return {"server": {"version": "2026.2.21-2"}}
|
||||
|
||||
async def _fake_openclaw_call(method: str, params: object = None, *, config: object) -> object:
|
||||
_ = (params, config)
|
||||
calls.append(method)
|
||||
raise AssertionError(f"unexpected method: {method}")
|
||||
return {"runtime": {"version": "2026.2.13"}}
|
||||
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
||||
|
||||
result = await gateway_compat.check_gateway_runtime_compatibility(
|
||||
result = await gateway_compat.check_gateway_version_compatibility(
|
||||
GatewayConfig(url="ws://gateway.example/ws"),
|
||||
minimum_version="2026.1.30",
|
||||
)
|
||||
|
||||
assert calls == []
|
||||
assert result.compatible is True
|
||||
assert result.current_version == "2026.2.21-2"
|
||||
assert result.compatible is False
|
||||
assert result.current_version is None
|
||||
assert "unable to determine gateway version" in (result.message or "").lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_gateway_runtime_compatibility_falls_back_to_health(
|
||||
async def test_check_gateway_version_compatibility_rejects_non_calver_server_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":
|
||||
raise OpenClawGatewayError("unknown method")
|
||||
if method == "status":
|
||||
raise OpenClawGatewayError("unknown method")
|
||||
return {"version": "2026.2.0"}
|
||||
|
||||
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
||||
_ = config
|
||||
return None
|
||||
return {"server": {"version": "dev"}}
|
||||
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_call", _fake_openclaw_call)
|
||||
|
||||
result = await gateway_compat.check_gateway_runtime_compatibility(
|
||||
result = await gateway_compat.check_gateway_version_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"
|
||||
assert result.compatible is False
|
||||
assert result.current_version == "dev"
|
||||
assert "unsupported version format" in (result.message or "").lower()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_gateway_runtime_compatibility_uses_health_when_status_has_no_version(
|
||||
async def test_check_gateway_version_compatibility_propagates_connect_errors(
|
||||
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"}
|
||||
|
||||
async def _fake_connect_metadata(*, config: GatewayConfig) -> object | None:
|
||||
_ = config
|
||||
return None
|
||||
raise OpenClawGatewayError("connection refused")
|
||||
|
||||
monkeypatch.setattr(gateway_compat, "openclaw_connect_metadata", _fake_connect_metadata)
|
||||
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"
|
||||
with pytest.raises(OpenClawGatewayError, match="connection refused"):
|
||||
await gateway_compat.check_gateway_version_compatibility(
|
||||
GatewayConfig(url="ws://gateway.example/ws"),
|
||||
minimum_version="2026.1.30",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@@ -172,7 +178,7 @@ async def test_admin_service_rejects_incompatible_gateway(
|
||||
message="Gateway version 2026.1.0 is not supported.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
||||
|
||||
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -190,7 +196,7 @@ async def test_admin_service_maps_gateway_transport_errors(
|
||||
_ = (config, minimum_version)
|
||||
raise OpenClawGatewayError("connection refused")
|
||||
|
||||
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
||||
|
||||
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -208,7 +214,7 @@ async def test_admin_service_maps_gateway_scope_errors_with_guidance(
|
||||
_ = (config, minimum_version)
|
||||
raise OpenClawGatewayError("missing scope: operator.read")
|
||||
|
||||
monkeypatch.setattr(admin_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(admin_service, "check_gateway_version_compatibility", _fake_check)
|
||||
|
||||
service = GatewayAdminLifecycleService(session=object()) # type: ignore[arg-type]
|
||||
with pytest.raises(HTTPException) as exc_info:
|
||||
@@ -231,7 +237,7 @@ async def test_gateway_status_reports_incompatible_version(
|
||||
message="Gateway version 2026.1.0 is not supported.",
|
||||
)
|
||||
|
||||
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
||||
|
||||
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
||||
response = await service.get_status(
|
||||
@@ -252,7 +258,7 @@ async def test_gateway_status_surfaces_scope_error_guidance(
|
||||
_ = (config, minimum_version)
|
||||
raise OpenClawGatewayError("missing scope: operator.read")
|
||||
|
||||
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
||||
|
||||
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
||||
response = await service.get_status(
|
||||
@@ -284,7 +290,7 @@ async def test_gateway_status_returns_sessions_when_version_compatible(
|
||||
assert method == "sessions.list"
|
||||
return {"sessions": [{"key": "agent:main"}]}
|
||||
|
||||
monkeypatch.setattr(session_service, "check_gateway_runtime_compatibility", _fake_check)
|
||||
monkeypatch.setattr(session_service, "check_gateway_version_compatibility", _fake_check)
|
||||
monkeypatch.setattr(session_service, "openclaw_call", _fake_openclaw_call)
|
||||
|
||||
service = GatewaySessionService(session=object()) # type: ignore[arg-type]
|
||||
|
||||
Reference in New Issue
Block a user