Skip to content

Commit 5dcc0fb

Browse files
fix(robot-server): get versions from VERSION.json (#12361)
In d7a9200 we switched to generating the version of the opentrons package from git, including feeding its __version__ by using importlib metadata instead of ingesting package.json. The problem is that __version__ now has a python-style semver string (e.g. 0.1.0a0) instead of a rest-of-the-world semver string (e.g. 0.1.0-alpha.0), which node semver doesn't like, which means that the app was rejecting the version the robot server reported in /health and falling back to the update server. This means things continued to work perfectly fine on OT-2, but on the flex a separate issue meant the update server wasn't reporting a version for the opentrons package, so versions broke. This change uses the VERSION.json file to get the "real" semver string for the opentrons package instead of the importlib metadata one. It also uses that file to load the system version rather than a config flag. There are fallbacks to the original versions of this implementation for both, which will be used in dev servers where VERSION.json is not present. Closes RQA-577 Co-authored-by: Max Marrone <[email protected]>
1 parent 9ed4666 commit 5dcc0fb

File tree

3 files changed

+202
-7
lines changed

3 files changed

+202
-7
lines changed

robot-server/robot_server/health/router.py

+79-2
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,92 @@
11
"""HTTP routes and handlers for /health endpoints."""
2+
from dataclasses import dataclass
23
from fastapi import APIRouter, Depends, status
4+
from typing import Dict, cast
5+
import logging
6+
import json
37

48
from opentrons import __version__, config, protocol_api
59
from opentrons.hardware_control import HardwareControlAPI
610

711
from robot_server.hardware import get_hardware
812
from robot_server.persistence import get_sql_engine as ensure_sql_engine_is_ready
913
from robot_server.service.legacy.models import V1BasicResponse
14+
from robot_server.util import call_once
1015
from .models import Health, HealthLinks
1116

17+
_log = logging.getLogger(__name__)
1218

1319
LOG_PATHS = ["/logs/serial.log", "/logs/api.log", "/logs/server.log"]
20+
VERSION_PATH = "/etc/VERSION.json"
21+
22+
23+
@dataclass
24+
class ComponentVersions:
25+
"""Holds the versions of system components."""
26+
27+
api_version: str
28+
system_version: str
29+
30+
31+
@call_once
32+
async def _get_version() -> Dict[str, str]:
33+
try:
34+
with open(VERSION_PATH, "r") as version_file:
35+
return cast(Dict[str, str], json.load(version_file))
36+
except FileNotFoundError:
37+
_log.warning(f"{VERSION_PATH} does not exist - is this a dev server?")
38+
return {}
39+
except OSError as ose:
40+
_log.warning(
41+
f"Could not open {VERSION_PATH}: {ose.errno}: {ose.strerror} - is this a dev server?"
42+
)
43+
return {}
44+
except json.JSONDecodeError as jde:
45+
_log.error(
46+
f"Could not parse {VERSION_PATH}: {jde.msg} at line {jde.lineno} col {jde.colno}"
47+
)
48+
return {}
49+
except Exception:
50+
_log.exception(f"Failed to read version from {VERSION_PATH}")
51+
return {}
52+
53+
54+
def _get_config_system_version() -> str:
55+
return config.OT_SYSTEM_VERSION
56+
57+
58+
def _get_api_version_dunder() -> str:
59+
return __version__
60+
61+
62+
async def get_versions() -> ComponentVersions:
63+
"""Dependency function for the versions of system components."""
64+
version_file = await _get_version()
65+
66+
def _api_version_or_fallback() -> str:
67+
if "opentrons_api_version" in version_file:
68+
return version_file["opentrons_api_version"]
69+
version_dunder = _get_api_version_dunder()
70+
_log.warning(
71+
f"Could not find api version in VERSION, falling back to {version_dunder}"
72+
)
73+
return version_dunder
74+
75+
def _system_version_or_fallback() -> str:
76+
if "buildroot_version" in version_file:
77+
return version_file["buildroot_version"]
78+
if "openembedded_version" in version_file:
79+
return version_file["openembedded_version"]
80+
config_version = _get_config_system_version()
81+
_log.warning(
82+
f"Could not find system version in VERSION, falling back to {config_version}"
83+
)
84+
return config_version
85+
86+
return ComponentVersions(
87+
api_version=_api_version_or_fallback(),
88+
system_version=_system_version_or_fallback(),
89+
)
1490

1591

1692
health_router = APIRouter()
@@ -37,6 +113,7 @@ async def get_health(
37113
# like viewing runs and uploading protocols, which would hit "database not ready"
38114
# errors that would present in a confusing way.
39115
sql_engine: object = Depends(ensure_sql_engine_is_ready),
116+
versions: ComponentVersions = Depends(get_versions),
40117
) -> Health:
41118
"""Get information about the health of the robot server.
42119
@@ -46,11 +123,11 @@ async def get_health(
46123
"""
47124
return Health(
48125
name=config.name(),
49-
api_version=__version__,
126+
api_version=versions.api_version,
50127
fw_version=hardware.fw_version,
51128
board_revision=hardware.board_revision,
52129
logs=LOG_PATHS,
53-
system_version=config.OT_SYSTEM_VERSION,
130+
system_version=versions.system_version,
54131
maximum_protocol_api_version=list(protocol_api.MAX_SUPPORTED_VERSION),
55132
minimum_protocol_api_version=list(protocol_api.MIN_SUPPORTED_VERSION),
56133
robot_model=(

robot-server/tests/conftest.py

+23
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@
3939
from robot_server.service.session.manager import SessionManager
4040
from robot_server.persistence import get_sql_engine, create_sql_engine
4141
from .integration.robot_client import RobotClient
42+
from robot_server.health.router import ComponentVersions, get_versions
4243

4344
test_router = routing.APIRouter()
4445

@@ -92,6 +93,16 @@ def hardware() -> MagicMock:
9293
return MagicMock(spec=API)
9394

9495

96+
@pytest.fixture
97+
def versions() -> MagicMock:
98+
m = MagicMock(spec=get_versions)
99+
m.return_value = ComponentVersions(
100+
api_version="someTestApiVersion",
101+
system_version="someTestSystemVersion",
102+
)
103+
return m
104+
105+
95106
@pytest.fixture
96107
def _override_hardware_with_mock(hardware: MagicMock) -> Iterator[None]:
97108
async def get_hardware_override() -> HardwareControlAPI:
@@ -114,10 +125,22 @@ async def get_sql_engine_override() -> SQLEngine:
114125
del app.dependency_overrides[get_sql_engine]
115126

116127

128+
@pytest.fixture
129+
def _override_version_with_mock(versions: MagicMock) -> Iterator[None]:
130+
async def get_version_override() -> ComponentVersions:
131+
"""Override for the get_versions() FastAPI dependency."""
132+
return cast(ComponentVersions, await versions())
133+
134+
app.dependency_overrides[get_versions] = get_version_override
135+
yield
136+
del app.dependency_overrides[get_versions]
137+
138+
117139
@pytest.fixture
118140
def api_client(
119141
_override_hardware_with_mock: None,
120142
_override_sql_engine_with_mock: None,
143+
_override_version_with_mock: None,
121144
) -> TestClient:
122145
client = TestClient(app)
123146
client.headers.update({API_VERSION_HEADER: LATEST_API_VERSION_HEADER_VALUE})

robot-server/tests/health/test_health_router.py

+100-5
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,31 @@
11
"""Tests for the /health router."""
2-
from mock import MagicMock
2+
import pytest
3+
from typing import Dict, Iterator
4+
from mock import MagicMock, patch
35
from starlette.testclient import TestClient
46

5-
from opentrons import __version__
67
from opentrons.protocol_api import MAX_SUPPORTED_VERSION, MIN_SUPPORTED_VERSION
78

9+
from robot_server.health.router import ComponentVersions, get_versions, _get_version
810

9-
def test_get_health(api_client: TestClient, hardware: MagicMock) -> None:
11+
12+
def test_get_health(
13+
api_client: TestClient, hardware: MagicMock, versions: MagicMock
14+
) -> None:
1015
"""Test GET /health."""
1116
hardware.fw_version = "FW111"
1217
hardware.board_revision = "BR2.1"
18+
versions.return_value = ComponentVersions(
19+
api_version="mytestapiversion", system_version="mytestsystemversion"
20+
)
1321

1422
expected = {
1523
"name": "opentrons-dev",
16-
"api_version": __version__,
24+
"api_version": "mytestapiversion",
1725
"fw_version": "FW111",
1826
"board_revision": "BR2.1",
1927
"logs": ["/logs/serial.log", "/logs/api.log", "/logs/server.log"],
20-
"system_version": "0.0.0",
28+
"system_version": "mytestsystemversion",
2129
"minimum_protocol_api_version": list(MIN_SUPPORTED_VERSION),
2230
"maximum_protocol_api_version": list(MAX_SUPPORTED_VERSION),
2331
"robot_model": "OT-2 Standard",
@@ -34,3 +42,90 @@ def test_get_health(api_client: TestClient, hardware: MagicMock) -> None:
3442
text = resp.json()
3543
assert resp.status_code == 200
3644
assert text == expected
45+
46+
47+
@pytest.fixture
48+
def mock_version_file_contents() -> Iterator[MagicMock]:
49+
"""Returns a mock for version file contents."""
50+
with patch("robot_server.health.router._get_version", spec=_get_version) as p:
51+
p.return_value = {}
52+
yield p
53+
54+
55+
@pytest.fixture
56+
def mock_config_version() -> Iterator[MagicMock]:
57+
"""Returns a mock for the config version."""
58+
with patch("robot_server.health.router._get_config_system_version") as p:
59+
p.return_value = "mysystemversion"
60+
yield p
61+
62+
63+
@pytest.fixture
64+
def mock_api_version() -> Iterator[MagicMock]:
65+
"""Returns a mock for the api version."""
66+
with patch("robot_server.health.router._get_api_version_dunder") as p:
67+
p.return_value = "myapiversion"
68+
yield p
69+
70+
71+
@pytest.mark.parametrize(
72+
"file_contents,config_system_version,api_version,computed_version",
73+
[
74+
(
75+
{},
76+
"rightsystemversion",
77+
"rightapiversion",
78+
ComponentVersions("rightapiversion", "rightsystemversion"),
79+
),
80+
(
81+
{"opentrons_api_version": "fileapiversion"},
82+
"rightsystemversion",
83+
"wrongapiversion",
84+
ComponentVersions("fileapiversion", "rightsystemversion"),
85+
),
86+
(
87+
{"buildroot_version": "filesystemversion"},
88+
"wrongsystemversion",
89+
"rightapiversion",
90+
ComponentVersions("rightapiversion", "filesystemversion"),
91+
),
92+
(
93+
{"openembedded_version": "filesystemversion"},
94+
"wrongsystemversion",
95+
"rightapiversion",
96+
ComponentVersions("rightapiversion", "filesystemversion"),
97+
),
98+
(
99+
{
100+
"opentrons_api_version": "fileapiversion",
101+
"buildroot_version": "filesystemversion",
102+
},
103+
"wrongsystemversion",
104+
"wrongapiversion",
105+
ComponentVersions("fileapiversion", "filesystemversion"),
106+
),
107+
(
108+
{
109+
"opentrons_api_version": "fileapiversion",
110+
"openembedded_version": "filesystemversion",
111+
},
112+
"wrongsystemversion",
113+
"wrongapiversion",
114+
ComponentVersions("fileapiversion", "filesystemversion"),
115+
),
116+
],
117+
)
118+
async def test_version_dependency(
119+
file_contents: Dict[str, str],
120+
config_system_version: str,
121+
api_version: str,
122+
computed_version: ComponentVersions,
123+
mock_version_file_contents: MagicMock,
124+
mock_config_version: MagicMock,
125+
mock_api_version: MagicMock,
126+
) -> None:
127+
"""Tests whether the version dependency function works."""
128+
mock_version_file_contents.return_value = file_contents
129+
mock_config_version.return_value = config_system_version
130+
mock_api_version.return_value = api_version
131+
assert (await get_versions()) == computed_version

0 commit comments

Comments
 (0)