Skip to content
13 changes: 6 additions & 7 deletions backend/qualibrate_app/api/__main__.py
Original file line number Diff line number Diff line change
@@ -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)
105 changes: 105 additions & 0 deletions backend/qualibrate_app/api/core/schemas/health.py
Original file line number Diff line number Diff line change
@@ -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
114 changes: 114 additions & 0 deletions backend/qualibrate_app/api/core/utils/runner.py
Original file line number Diff line number Diff line change
@@ -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
3 changes: 2 additions & 1 deletion backend/qualibrate_app/api/routes/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down
102 changes: 102 additions & 0 deletions backend/qualibrate_app/api/routes/other.py
Original file line number Diff line number Diff line change
@@ -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),
)
6 changes: 6 additions & 0 deletions backend/qualibrate_app/versions_matching.py
Original file line number Diff line number Diff line change
@@ -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")),
}
Loading