From 37810da11c75e5f0bd9a85799ad1f62a9b6e04e1 Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Wed, 20 Aug 2025 15:35:47 +0300 Subject: [PATCH 1/9] feat: health route --- backend/qualibrate_app/api/__main__.py | 13 ++-- .../api/core/schemas/state_updates.py | 18 +++++- .../qualibrate_app/api/core/utils/runner.py | 59 +++++++++++++++++++ backend/qualibrate_app/api/routes/__init__.py | 3 +- backend/qualibrate_app/api/routes/other.py | 37 ++++++++++++ 5 files changed, 121 insertions(+), 9 deletions(-) create mode 100644 backend/qualibrate_app/api/core/utils/runner.py create mode 100644 backend/qualibrate_app/api/routes/other.py diff --git a/backend/qualibrate_app/api/__main__.py b/backend/qualibrate_app/api/__main__.py index 272218ed..c05787c2 100644 --- a/backend/qualibrate_app/api/__main__.py +++ b/backend/qualibrate_app/api/__main__.py @@ -1,14 +1,13 @@ from fastapi import APIRouter -from qualibrate_app.api.routes import project_router, storage_router +from qualibrate_app.api.routes import ( + other_router, + project_router, + storage_router, +) api_router = APIRouter() - -@api_router.get("/") -def ping() -> str: - return "pong" - - +api_router.include_router(other_router) api_router.include_router(project_router) api_router.include_router(storage_router) diff --git a/backend/qualibrate_app/api/core/schemas/state_updates.py b/backend/qualibrate_app/api/core/schemas/state_updates.py index 14ad2b81..a2ab5202 100644 --- a/backend/qualibrate_app/api/core/schemas/state_updates.py +++ b/backend/qualibrate_app/api/core/schemas/state_updates.py @@ -1,7 +1,7 @@ from collections.abc import Mapping from typing import Any -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field class StateUpdate(BaseModel): @@ -26,3 +26,19 @@ class StateUpdateRequestItem(BaseModel): class StateUpdateRequestItems(BaseModel): items: list[StateUpdateRequestItem] + + +class RunnerVersionValid(BaseModel): + url: HttpUrl + version: str + is_valid: bool + + +class HealthCheck(BaseModel): + frontend_version: str + backend_version: str + runners_status: list[RunnerVersionValid] + + @computed_field + def is_valid(self) -> bool: + return self.frontend_version.lstrip("v") == self.backend_version diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py new file mode 100644 index 00000000..688fefaf --- /dev/null +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -0,0 +1,59 @@ +from collections.abc import MutableMapping +from typing import Any, Optional +from urllib.parse import urljoin + +import requests +from packaging.version import Version +from qualibrate_config.models import QualibrateConfig + +from qualibrate_app.api.core.schemas.state_updates import RunnerVersionValid +from qualibrate_app.api.core.utils.request_utils import get_runner_config + +APP2RUNNER_VERSIONS: dict[Version, tuple[Version, Version]] = { + Version("0.3.6"): (Version("0.3.6"), Version("0.4.0")), +} + + +def validate_runner_version( + app_version: str, runner_version: Optional[str] +) -> bool: + if runner_version is None: + return False + app_v = Version(app_version) + runner_range = APP2RUNNER_VERSIONS.get(app_v) + if runner_range is None: + runner_range = APP2RUNNER_VERSIONS.get(Version(app_v.base_version)) + if runner_range is None: + return False + runner_v = Version(runner_version) + return runner_range[0] <= runner_v <= runner_range[1] + + +def get_runner_statuses( + app_version: str, + settings: QualibrateConfig, + cookies: MutableMapping[str, Any], +) -> list[RunnerVersionValid]: + try: + runner_config = get_runner_config(settings) + except RuntimeError: + return [] + response = requests.get( + urljoin(runner_config.address_with_root, "meta"), + cookies=cookies, + timeout=runner_config.timeout, + ) + if response.status_code != requests.codes.ok: + return [] + try: + data = response.json() + runner_v = data.get("version") + return [ + RunnerVersionValid( + version=runner_v, + url=runner_config.address, # type: ignore[arg-type] # TODO + is_valid=validate_runner_version(app_version, runner_v), + ) + ] + except requests.exceptions.JSONDecodeError: + return [] diff --git a/backend/qualibrate_app/api/routes/__init__.py b/backend/qualibrate_app/api/routes/__init__.py index b9371c35..3b1cb2b8 100644 --- a/backend/qualibrate_app/api/routes/__init__.py +++ b/backend/qualibrate_app/api/routes/__init__.py @@ -2,11 +2,12 @@ from qualibrate_app.api.routes.branch import branch_router from qualibrate_app.api.routes.data_file import data_file_router +from qualibrate_app.api.routes.other import other_router from qualibrate_app.api.routes.project import project_router from qualibrate_app.api.routes.root import root_router from qualibrate_app.api.routes.snapshot import snapshot_router -__all__ = ["project_router", "storage_router"] +__all__ = ["other_router", "project_router", "storage_router"] storage_router = APIRouter() diff --git a/backend/qualibrate_app/api/routes/other.py b/backend/qualibrate_app/api/routes/other.py new file mode 100644 index 00000000..0b11ca05 --- /dev/null +++ b/backend/qualibrate_app/api/routes/other.py @@ -0,0 +1,37 @@ +from importlib.metadata import version +from typing import Annotated, Union + +from fastapi import APIRouter, Cookie, Depends +from qualibrate_config.models import QualibrateConfig + +from qualibrate_app.api.core.schemas.state_updates import HealthCheck +from qualibrate_app.api.core.utils.runner import get_runner_statuses +from qualibrate_app.config import get_settings + +other_router = APIRouter(tags=["other"]) + + +@other_router.get("/") +def ping() -> str: + return "pong" + + +@other_router.get("/health") +def health( + frontend_version: str, + settings: Annotated[QualibrateConfig, Depends(get_settings)], + qualibrate_token: Annotated[ + Union[str, None], Cookie(alias="Qualibrate-Token") + ] = None, +) -> HealthCheck: + cookies = ( + {"Qualibrate-Token": qualibrate_token} + if qualibrate_token is not None + else {} + ) + app_v = version("qualibrate_app") + return HealthCheck( + backend_version=app_v, + frontend_version=frontend_version, + runners_status=get_runner_statuses(app_v, settings, cookies=cookies), + ) From f48fa2ab30213bc3d96444bc39be290782cfaf66 Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Mon, 8 Sep 2025 10:55:37 +0300 Subject: [PATCH 2/9] feat: validate that passed frontend version is valid version --- backend/qualibrate_app/api/core/schemas/state_updates.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/backend/qualibrate_app/api/core/schemas/state_updates.py b/backend/qualibrate_app/api/core/schemas/state_updates.py index a2ab5202..56bef26f 100644 --- a/backend/qualibrate_app/api/core/schemas/state_updates.py +++ b/backend/qualibrate_app/api/core/schemas/state_updates.py @@ -1,6 +1,7 @@ from collections.abc import Mapping from typing import Any +from packaging.version import InvalidVersion, Version from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field @@ -41,4 +42,8 @@ class HealthCheck(BaseModel): @computed_field def is_valid(self) -> bool: + try: + Version(self.frontend_version) + except InvalidVersion: + return False return self.frontend_version.lstrip("v") == self.backend_version From b8ea34350d58b35a7f671db7864bc4668f2fb41e Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Mon, 8 Sep 2025 10:55:58 +0300 Subject: [PATCH 3/9] feat: catch exception on request runner meta --- backend/qualibrate_app/api/core/utils/runner.py | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 688fefaf..84322ad9 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -38,11 +38,14 @@ def get_runner_statuses( runner_config = get_runner_config(settings) except RuntimeError: return [] - response = requests.get( - urljoin(runner_config.address_with_root, "meta"), - cookies=cookies, - timeout=runner_config.timeout, - ) + try: + response = requests.get( + urljoin(runner_config.address_with_root, "meta"), + cookies=cookies, + timeout=runner_config.timeout, + ) + except requests.exceptions.RequestException: + return [] if response.status_code != requests.codes.ok: return [] try: From e8082565b7b5df1925a9dfcdb81f319f3ca354df Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Mon, 8 Sep 2025 11:22:02 +0300 Subject: [PATCH 4/9] fix: only single runner can be per project --- .../api/core/schemas/state_updates.py | 8 ++--- .../qualibrate_app/api/core/utils/runner.py | 34 ++++++++++++------- backend/qualibrate_app/api/routes/other.py | 4 +-- 3 files changed, 27 insertions(+), 19 deletions(-) diff --git a/backend/qualibrate_app/api/core/schemas/state_updates.py b/backend/qualibrate_app/api/core/schemas/state_updates.py index 56bef26f..e29d384c 100644 --- a/backend/qualibrate_app/api/core/schemas/state_updates.py +++ b/backend/qualibrate_app/api/core/schemas/state_updates.py @@ -1,5 +1,5 @@ from collections.abc import Mapping -from typing import Any +from typing import Any, Optional from packaging.version import InvalidVersion, Version from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field @@ -30,15 +30,15 @@ class StateUpdateRequestItems(BaseModel): class RunnerVersionValid(BaseModel): - url: HttpUrl - version: str + url: Optional[HttpUrl] + version: Optional[str] is_valid: bool class HealthCheck(BaseModel): frontend_version: str backend_version: str - runners_status: list[RunnerVersionValid] + runners_status: RunnerVersionValid @computed_field def is_valid(self) -> bool: diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 84322ad9..4ba42367 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -29,15 +29,24 @@ def validate_runner_version( return runner_range[0] <= runner_v <= runner_range[1] -def get_runner_statuses( +def get_runner_status( app_version: str, settings: QualibrateConfig, cookies: MutableMapping[str, Any], -) -> list[RunnerVersionValid]: +) -> RunnerVersionValid: try: runner_config = get_runner_config(settings) except RuntimeError: - return [] + return RunnerVersionValid( + version=None, + url=None, + is_valid=False, + ) + invalid_runner = RunnerVersionValid( + version=None, + url=runner_config.address, + is_valid=False, + ) try: response = requests.get( urljoin(runner_config.address_with_root, "meta"), @@ -45,18 +54,17 @@ def get_runner_statuses( timeout=runner_config.timeout, ) except requests.exceptions.RequestException: - return [] + return invalid_runner if response.status_code != requests.codes.ok: - return [] + return invalid_runner try: data = response.json() runner_v = data.get("version") - return [ - RunnerVersionValid( - version=runner_v, - url=runner_config.address, # type: ignore[arg-type] # TODO - is_valid=validate_runner_version(app_version, runner_v), - ) - ] + return RunnerVersionValid( + version=runner_v, + url=runner_config.address, + is_valid=validate_runner_version(app_version, runner_v), + ) + except requests.exceptions.JSONDecodeError: - return [] + return invalid_runner diff --git a/backend/qualibrate_app/api/routes/other.py b/backend/qualibrate_app/api/routes/other.py index 0b11ca05..778c09eb 100644 --- a/backend/qualibrate_app/api/routes/other.py +++ b/backend/qualibrate_app/api/routes/other.py @@ -5,7 +5,7 @@ from qualibrate_config.models import QualibrateConfig from qualibrate_app.api.core.schemas.state_updates import HealthCheck -from qualibrate_app.api.core.utils.runner import get_runner_statuses +from qualibrate_app.api.core.utils.runner import get_runner_status from qualibrate_app.config import get_settings other_router = APIRouter(tags=["other"]) @@ -33,5 +33,5 @@ def health( return HealthCheck( backend_version=app_v, frontend_version=frontend_version, - runners_status=get_runner_statuses(app_v, settings, cookies=cookies), + runners_status=get_runner_status(app_v, settings, cookies=cookies), ) From 2e7138936391311f54125e059cdbc5b3af21faca Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Mon, 8 Sep 2025 11:22:46 +0300 Subject: [PATCH 5/9] feat: handle pre-releases when checking runner version --- backend/qualibrate_app/api/core/utils/runner.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 4ba42367..5958af32 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -11,6 +11,7 @@ APP2RUNNER_VERSIONS: dict[Version, tuple[Version, Version]] = { Version("0.3.6"): (Version("0.3.6"), Version("0.4.0")), + Version("0.4.0"): (Version("0.4.0"), Version("0.4.0")), } @@ -26,7 +27,12 @@ def validate_runner_version( if runner_range is None: return False runner_v = Version(runner_version) - return runner_range[0] <= runner_v <= runner_range[1] + if not runner_v.is_prerelease: + return runner_range[0] <= runner_v <= runner_range[1] + return ( + runner_range[0] <= Version(app_v.base_version) + and runner_v <= runner_range[1] + ) def get_runner_status( From 79ac8934ada8eae0fe203f3f3bf9b9681917cfea Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Mon, 8 Sep 2025 13:59:05 +0300 Subject: [PATCH 6/9] refactor: use singular for runner status --- backend/qualibrate_app/api/core/schemas/state_updates.py | 2 +- backend/qualibrate_app/api/routes/other.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/qualibrate_app/api/core/schemas/state_updates.py b/backend/qualibrate_app/api/core/schemas/state_updates.py index e29d384c..3d70174c 100644 --- a/backend/qualibrate_app/api/core/schemas/state_updates.py +++ b/backend/qualibrate_app/api/core/schemas/state_updates.py @@ -38,7 +38,7 @@ class RunnerVersionValid(BaseModel): class HealthCheck(BaseModel): frontend_version: str backend_version: str - runners_status: RunnerVersionValid + runner_status: RunnerVersionValid @computed_field def is_valid(self) -> bool: diff --git a/backend/qualibrate_app/api/routes/other.py b/backend/qualibrate_app/api/routes/other.py index 778c09eb..c58a0269 100644 --- a/backend/qualibrate_app/api/routes/other.py +++ b/backend/qualibrate_app/api/routes/other.py @@ -33,5 +33,5 @@ def health( return HealthCheck( backend_version=app_v, frontend_version=frontend_version, - runners_status=get_runner_status(app_v, settings, cookies=cookies), + runner_status=get_runner_status(app_v, settings, cookies=cookies), ) From caed43283698394f6fc1affb46f2d7aa2ebbd298 Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Tue, 9 Sep 2025 12:11:06 +0300 Subject: [PATCH 7/9] refactor: move health schemas to separated module --- .../qualibrate_app/api/core/schemas/health.py | 24 ++++++++++++++++++ .../api/core/schemas/state_updates.py | 25 ++----------------- .../qualibrate_app/api/core/utils/runner.py | 2 +- backend/qualibrate_app/api/routes/other.py | 2 +- 4 files changed, 28 insertions(+), 25 deletions(-) create mode 100644 backend/qualibrate_app/api/core/schemas/health.py diff --git a/backend/qualibrate_app/api/core/schemas/health.py b/backend/qualibrate_app/api/core/schemas/health.py new file mode 100644 index 00000000..4e365796 --- /dev/null +++ b/backend/qualibrate_app/api/core/schemas/health.py @@ -0,0 +1,24 @@ +from typing import Optional + +from packaging.version import InvalidVersion, Version +from pydantic import BaseModel, HttpUrl, computed_field + + +class RunnerVersionValid(BaseModel): + url: Optional[HttpUrl] + version: Optional[str] + is_valid: bool + + +class HealthCheck(BaseModel): + frontend_version: str + backend_version: str + runner_status: RunnerVersionValid + + @computed_field + def is_valid(self) -> bool: + try: + Version(self.frontend_version) + except InvalidVersion: + return False + return self.frontend_version.lstrip("v") == self.backend_version diff --git a/backend/qualibrate_app/api/core/schemas/state_updates.py b/backend/qualibrate_app/api/core/schemas/state_updates.py index 3d70174c..14ad2b81 100644 --- a/backend/qualibrate_app/api/core/schemas/state_updates.py +++ b/backend/qualibrate_app/api/core/schemas/state_updates.py @@ -1,8 +1,7 @@ from collections.abc import Mapping -from typing import Any, Optional +from typing import Any -from packaging.version import InvalidVersion, Version -from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field +from pydantic import BaseModel, ConfigDict, Field class StateUpdate(BaseModel): @@ -27,23 +26,3 @@ class StateUpdateRequestItem(BaseModel): class StateUpdateRequestItems(BaseModel): items: list[StateUpdateRequestItem] - - -class RunnerVersionValid(BaseModel): - url: Optional[HttpUrl] - version: Optional[str] - is_valid: bool - - -class HealthCheck(BaseModel): - frontend_version: str - backend_version: str - runner_status: RunnerVersionValid - - @computed_field - def is_valid(self) -> bool: - try: - Version(self.frontend_version) - except InvalidVersion: - return False - return self.frontend_version.lstrip("v") == self.backend_version diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 5958af32..1b9cb0f2 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -6,7 +6,7 @@ from packaging.version import Version from qualibrate_config.models import QualibrateConfig -from qualibrate_app.api.core.schemas.state_updates import RunnerVersionValid +from qualibrate_app.api.core.schemas.health import RunnerVersionValid from qualibrate_app.api.core.utils.request_utils import get_runner_config APP2RUNNER_VERSIONS: dict[Version, tuple[Version, Version]] = { diff --git a/backend/qualibrate_app/api/routes/other.py b/backend/qualibrate_app/api/routes/other.py index c58a0269..77b676a2 100644 --- a/backend/qualibrate_app/api/routes/other.py +++ b/backend/qualibrate_app/api/routes/other.py @@ -4,7 +4,7 @@ from fastapi import APIRouter, Cookie, Depends from qualibrate_config.models import QualibrateConfig -from qualibrate_app.api.core.schemas.state_updates import HealthCheck +from qualibrate_app.api.core.schemas.health import HealthCheck from qualibrate_app.api.core.utils.runner import get_runner_status from qualibrate_app.config import get_settings From ba5b0edc722d08782589d6933443000de019c85c Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Tue, 9 Sep 2025 12:13:22 +0300 Subject: [PATCH 8/9] refactor: move versions matching mapping to package root --- backend/qualibrate_app/api/core/utils/runner.py | 6 +----- backend/qualibrate_app/versions_matching.py | 6 ++++++ 2 files changed, 7 insertions(+), 5 deletions(-) create mode 100644 backend/qualibrate_app/versions_matching.py diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 1b9cb0f2..017a418f 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -8,11 +8,7 @@ from qualibrate_app.api.core.schemas.health import RunnerVersionValid from qualibrate_app.api.core.utils.request_utils import get_runner_config - -APP2RUNNER_VERSIONS: dict[Version, tuple[Version, Version]] = { - Version("0.3.6"): (Version("0.3.6"), Version("0.4.0")), - Version("0.4.0"): (Version("0.4.0"), Version("0.4.0")), -} +from qualibrate_app.versions_matching import APP2RUNNER_VERSIONS def validate_runner_version( diff --git a/backend/qualibrate_app/versions_matching.py b/backend/qualibrate_app/versions_matching.py new file mode 100644 index 00000000..3fcc2232 --- /dev/null +++ b/backend/qualibrate_app/versions_matching.py @@ -0,0 +1,6 @@ +from packaging.version import Version + +APP2RUNNER_VERSIONS: dict[Version, tuple[Version, Version]] = { + Version("0.3.6"): (Version("0.3.6"), Version("0.4.0")), + Version("0.4.0"): (Version("0.4.0"), Version("0.4.0")), +} From 7d28ccd547d9a4a06650114ba1cb8933b98446f1 Mon Sep 17 00:00:00 2001 From: Maxim V4S Date: Tue, 9 Sep 2025 12:29:49 +0300 Subject: [PATCH 9/9] docs: add health description --- .../qualibrate_app/api/core/schemas/health.py | 93 +++++++++++++++++-- .../qualibrate_app/api/core/utils/runner.py | 42 +++++++++ backend/qualibrate_app/api/routes/other.py | 73 ++++++++++++++- 3 files changed, 198 insertions(+), 10 deletions(-) diff --git a/backend/qualibrate_app/api/core/schemas/health.py b/backend/qualibrate_app/api/core/schemas/health.py index 4e365796..76f0ee98 100644 --- a/backend/qualibrate_app/api/core/schemas/health.py +++ b/backend/qualibrate_app/api/core/schemas/health.py @@ -1,22 +1,103 @@ from typing import Optional from packaging.version import InvalidVersion, Version -from pydantic import BaseModel, HttpUrl, computed_field +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field class RunnerVersionValid(BaseModel): - url: Optional[HttpUrl] - version: Optional[str] - is_valid: bool + """ + Status payload describing the Runner service that the app talks to. + + Use it to surface the runner URL we tried, what version it reported, + and whether that version matches the app contract. + """ + + model_config = ConfigDict( + title="RunnerVersionValid", + json_schema_extra={ + "examples": [ + { + "url": "https://runner.example.com", + "version": "1.4.0", + "is_valid": True, + }, + { + "url": "http://localhost:9000", + "version": None, + "is_valid": False, + }, + ] + }, + ) + + url: Optional[HttpUrl] = Field( + default=None, + description="Base URL of the Runner service that was contacted.", + ) + version: Optional[str] = Field( + default=None, + description="Semantic version string returned by Runner meta endpoint.", + examples=["1.4.0", "v1.4.0"], + ) + is_valid: bool = Field( + description="True if Runner version is compatible with the app." + ) class HealthCheck(BaseModel): - frontend_version: str - backend_version: str + """ + End to end health snapshot joining frontend, backend, and Runner status. + + The `is_valid` field confirms that the frontend and backend versions match + after stripping a leading 'v' from the frontend string. + """ + + model_config = ConfigDict( + title="HealthCheck", + json_schema_extra={ + "examples": [ + { + "frontend_version": "v1.4.0", + "backend_version": "1.4.0", + "runner_status": { + "url": "https://runner.example.com", + "version": "1.4.0", + "is_valid": True, + }, + "is_valid": True, + }, + { + "frontend_version": "v1.5.0", + "backend_version": "1.4.0", + "runner_status": { + "url": "http://localhost:9000", + "version": None, + "is_valid": False, + }, + "is_valid": False, + }, + ] + }, + ) + + frontend_version: str = Field( + description=( + "Version string from the frontend client, often prefixed with 'v'." + ), + examples=["v1.4.0", "1.4.0"], + ) + backend_version: str = Field( + description="Installed backend package version.", + examples=["1.4.0"], + ) runner_status: RunnerVersionValid + "Status of the external Runner service." @computed_field def is_valid(self) -> bool: + """ + True if the frontend and backend versions match, ignoring a leading 'v'. + """ try: Version(self.frontend_version) except InvalidVersion: diff --git a/backend/qualibrate_app/api/core/utils/runner.py b/backend/qualibrate_app/api/core/utils/runner.py index 017a418f..60e6a449 100644 --- a/backend/qualibrate_app/api/core/utils/runner.py +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -14,6 +14,33 @@ def validate_runner_version( app_version: str, runner_version: Optional[str] ) -> bool: + """Validate that a Runner version is compatible with the app version. + + The validation is based on the mapping defined in + ``APP2RUNNER_VERSIONS``. Each app version corresponds to a minimum + and maximum supported Runner version. Pre-release Runner versions + are handled specially: they are allowed only if they are not below + the minimum required app base version and not above the maximum + supported Runner version. + + Args: + app_version (str): The backend app version string, e.g. "1.4.0". + runner_version (Optional[str]): The version string reported by + the Runner service. If ``None``, the function immediately + returns ``False``. + + Returns: + bool: True if the Runner version is valid for the given app + version, otherwise False. + + Examples: + >>> validate_runner_version("1.4.0", "1.4.0") + True + >>> validate_runner_version("1.4.0", None) + False + >>> validate_runner_version("1.4.0", "2.0.0") + False + """ if runner_version is None: return False app_v = Version(app_version) @@ -36,6 +63,21 @@ def get_runner_status( settings: QualibrateConfig, cookies: MutableMapping[str, Any], ) -> RunnerVersionValid: + """ + Query the Runner meta endpoint and validate its version against the app. + + Args: + app_version: Backend application version used as the contract reference. + settings: App configuration that includes Runner address and timeout. + cookies: Auth cookies to forward to the Runner call. + + Returns: + Runner base URL, reported version, and compatibility flag. + + Notes: + If the Runner is unreachable or returns a non OK status, + the result will carry `is_valid=False` and `version=None`. + """ try: runner_config = get_runner_config(settings) except RuntimeError: diff --git a/backend/qualibrate_app/api/routes/other.py b/backend/qualibrate_app/api/routes/other.py index 77b676a2..0ac9f23f 100644 --- a/backend/qualibrate_app/api/routes/other.py +++ b/backend/qualibrate_app/api/routes/other.py @@ -1,7 +1,7 @@ from importlib.metadata import version from typing import Annotated, Union -from fastapi import APIRouter, Cookie, Depends +from fastapi import APIRouter, Cookie, Depends, Query from qualibrate_config.models import QualibrateConfig from qualibrate_app.api.core.schemas.health import HealthCheck @@ -16,14 +16,79 @@ def ping() -> str: return "pong" -@other_router.get("/health") +@other_router.get( + "/health", + summary="Health and version compatibility check", + response_model=HealthCheck, + responses={ + 200: { + "description": "Health snapshot", + "content": { + "application/json": { + "examples": { + "ok_all_match": { + "summary": ( + "All services healthy and versions match" + ), + "value": { + "frontend_version": "v1.4.0", + "backend_version": "1.4.0", + "runner_status": { + "url": "https://runner.example.com", + "version": "1.4.0", + "is_valid": True, + }, + "is_valid": True, + }, + }, + "runner_down_or_mismatch": { + "summary": "Runner down or incompatible version", + "value": { + "frontend_version": "v1.5.0", + "backend_version": "1.4.0", + "runner_status": { + "url": "http://localhost:9000", + "version": None, + "is_valid": False, + }, + "is_valid": False, + }, + }, + } + } + }, + } + }, +) def health( - frontend_version: str, + frontend_version: Annotated[ + str, + Query( + ..., + description=( + "Client supplied frontend version. A leading 'v' is allowed " + "and will be ignored for the comparison. Should be extracted " + "from `manifest.json`" + ), + examples=["v1.4.0", "1.4.0"], + ), + ], settings: Annotated[QualibrateConfig, Depends(get_settings)], qualibrate_token: Annotated[ - Union[str, None], Cookie(alias="Qualibrate-Token") + Union[str, None], + Cookie( + alias="Qualibrate-Token", + description=( + "Session token, forwarded to Runner for authenticated " + "meta calls." + ), + ), ] = None, ) -> HealthCheck: + """ + Returns backend version, compares it to the provided frontend version, and + reports Runner reachability and version compatibility. + """ cookies = ( {"Qualibrate-Token": qualibrate_token} if qualibrate_token is not None