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/health.py b/backend/qualibrate_app/api/core/schemas/health.py new file mode 100644 index 00000000..76f0ee98 --- /dev/null +++ b/backend/qualibrate_app/api/core/schemas/health.py @@ -0,0 +1,105 @@ +from typing import Optional + +from packaging.version import InvalidVersion, Version +from pydantic import BaseModel, ConfigDict, Field, HttpUrl, computed_field + + +class RunnerVersionValid(BaseModel): + """ + 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): + """ + 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: + 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 new file mode 100644 index 00000000..60e6a449 --- /dev/null +++ b/backend/qualibrate_app/api/core/utils/runner.py @@ -0,0 +1,114 @@ +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.health import RunnerVersionValid +from qualibrate_app.api.core.utils.request_utils import get_runner_config +from qualibrate_app.versions_matching import APP2RUNNER_VERSIONS + + +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) + 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) + 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( + app_version: str, + 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: + 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"), + cookies=cookies, + timeout=runner_config.timeout, + ) + except requests.exceptions.RequestException: + return invalid_runner + if response.status_code != requests.codes.ok: + return invalid_runner + try: + data = response.json() + runner_v = data.get("version") + return RunnerVersionValid( + version=runner_v, + url=runner_config.address, + is_valid=validate_runner_version(app_version, runner_v), + ) + + except requests.exceptions.JSONDecodeError: + return invalid_runner 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..0ac9f23f --- /dev/null +++ b/backend/qualibrate_app/api/routes/other.py @@ -0,0 +1,102 @@ +from importlib.metadata import version +from typing import Annotated, Union + +from fastapi import APIRouter, Cookie, Depends, Query +from qualibrate_config.models import QualibrateConfig + +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 + +other_router = APIRouter(tags=["other"]) + + +@other_router.get("/") +def ping() -> str: + return "pong" + + +@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: 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", + 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 + else {} + ) + app_v = version("qualibrate_app") + return HealthCheck( + backend_version=app_v, + frontend_version=frontend_version, + runner_status=get_runner_status(app_v, settings, cookies=cookies), + ) 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")), +}