From 2f9f8df75189e9afa1ca8679b94ef4413eb403fb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Marcone=20Ten=C3=B3rio=20da=20Silva=20Filho?= Date: Thu, 6 Aug 2026 10:56:09 +0200 Subject: [PATCH] feat(runtime): publish minEditorVersion at /api/capabilities (DOPE-448) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now nothing let a runtime state what it needs from an editor: the upload endpoint accepts any ZIP that passes analyze_zip, and the bundle carries no editor identity at all. So "this runtime must not accept programs from an older editor" was not expressible. The new endpoint declares it. Unauthenticated, like /api/version — an editor calls it before login, because an editor too old to log in must still be able to find out why. The runtime only ADVERTISES this value; the editor compares it and refuses to upload. That is deliberate. Nothing on the upload path enforces it, so shipping a runtime release can never lock out an editor already installed in the field, and there is one place to debug when a push is refused. It is not a security control and should not be described as one. MIN_EDITOR_VERSION starts at 4.1.0 because that is where the STruC++ pipeline landed — 4.0.x editors emitted MatIEC artefacts this runtime cannot build at all. It is not a build counter: raising it for a release that merely changed something locks out working editors for no reason. Editors predating the endpoint get a 401 from the / catch-all (not a 404) and fall back to /api/version, seeing no floor — exactly their previous behaviour. Verified against a real pre-change container. Co-Authored-By: Claude Opus 5 --- docs/EDITOR_INTEGRATION.md | 14 ++++- tests/pytest/restapi/test_capabilities.py | 74 +++++++++++++++++++++++ webserver/restapi.py | 45 +++++++++++++- webserver/version.py | 18 ++++++ 4 files changed, 149 insertions(+), 2 deletions(-) create mode 100644 tests/pytest/restapi/test_capabilities.py diff --git a/docs/EDITOR_INTEGRATION.md b/docs/EDITOR_INTEGRATION.md index b619f83c..7a7d4926 100644 --- a/docs/EDITOR_INTEGRATION.md +++ b/docs/EDITOR_INTEGRATION.md @@ -206,6 +206,18 @@ The runtime uses self-signed TLS certificates by default. The OpenPLC Editor han ## API Endpoints Summary +### Version and compatibility +- `GET /api/version` - Runtime version string +- `GET /api/capabilities` - Runtime version plus `minEditorVersion`, the oldest + editor this runtime accepts programs from + +Both are unauthenticated: the Editor calls them before login to decide whether +it may talk to this device. `minEditorVersion` is **advertised, not enforced** — +the Editor compares it against its own version and refuses to upload, so a +runtime release can never lock out an editor already installed in the field. +Editors that predate `/api/capabilities` get a 404 and fall back to +`/api/version`, seeing no editor floor (their previous behaviour). + ### Authentication - `POST /api/create-user` - Create user account - `POST /api/login` - Login and get JWT token @@ -227,7 +239,7 @@ The runtime uses self-signed TLS certificates by default. The OpenPLC Editor han ### Debug Interface - `wss://host:8443/api/debug` - WebSocket debug interface -All endpoints except `/api/create-user` (first user only), `/api/login`, and `/api/get-users-info` require JWT authentication. +All endpoints except `/api/version`, `/api/capabilities`, `/api/create-user` (first user only), `/api/login`, and `/api/get-users-info` require JWT authentication. ## Error Handling diff --git a/tests/pytest/restapi/test_capabilities.py b/tests/pytest/restapi/test_capabilities.py new file mode 100644 index 00000000..43b5c1a0 --- /dev/null +++ b/tests/pytest/restapi/test_capabilities.py @@ -0,0 +1,74 @@ +"""Behavioural tests for GET /api/version and GET /api/capabilities. + +Both endpoints exist so an editor can decide, before login, whether it may +talk to this runtime at all (DOPE-448). The contract these tests pin down: + + * both are reachable WITHOUT a token, even once users exist — an editor that + cannot authenticate must still be able to tell why; + * ``/api/capabilities`` reports the same version string as ``/api/version``, + so the two can never disagree about what runtime this is; + * ``minEditorVersion`` is present and parseable as a version, because the + editor compares it numerically and a malformed value would either block + every editor or none. +""" + +import re + +from webserver.version import MIN_EDITOR_VERSION, RUNTIME_VERSION + +from conftest import create_user + +_VERSION_RE = re.compile(r"^\d+\.\d+\.\d+$") + + +# --- reachability --------------------------------------------------------- + + +def test_capabilities_is_reachable_without_a_token(client): + resp = client.get("/api/capabilities") + assert resp.status_code == 200 + + +def test_capabilities_stays_unauthenticated_once_users_exist(client): + # The editor needs the compatibility answer even when it holds no + # credentials for this device, so creating a user must not close the door. + create_user(client, "admin", "admin-pass") + assert client.get("/api/capabilities").status_code == 200 + + +def test_version_is_reachable_without_a_token(client): + assert client.get("/api/version").status_code == 200 + + +# --- payload -------------------------------------------------------------- + + +def test_capabilities_reports_runtime_version_and_editor_floor(client): + body = client.get("/api/capabilities").get_json() + assert body == { + "runtimeVersion": RUNTIME_VERSION, + "minEditorVersion": MIN_EDITOR_VERSION, + } + + +def test_capabilities_and_version_agree_on_the_runtime_version(client): + capabilities = client.get("/api/capabilities").get_json() + version = client.get("/api/version").get_json() + assert capabilities["runtimeVersion"] == version["version"] + + +def test_min_editor_version_is_a_plain_three_part_version(): + # The editor parses this and compares it against its own APP_VERSION. A + # tag-style value ("v4.1.0") or a partial one ("4.1") would make that + # comparison ambiguous, so the published floor stays a bare x.y.z. + assert _VERSION_RE.match(MIN_EDITOR_VERSION), MIN_EDITOR_VERSION + + +# --- headers -------------------------------------------------------------- + + +def test_runtime_version_header_is_present_on_capabilities(client): + # Older editors read the version off this header rather than a body; the + # after_request hook must cover the new route too. + resp = client.get("/api/capabilities") + assert resp.headers["X-OpenPLC-Runtime-Version"] == RUNTIME_VERSION diff --git a/webserver/restapi.py b/webserver/restapi.py index 2b7701e7..4cee074c 100644 --- a/webserver/restapi.py +++ b/webserver/restapi.py @@ -17,7 +17,7 @@ import webserver.config from webserver.logger import get_logger -from webserver.version import RUNTIME_VERSION +from webserver.version import MIN_EDITOR_VERSION, RUNTIME_VERSION logger, buffer = get_logger("logger", use_buffer=True) @@ -72,6 +72,49 @@ def restapi_version(): return jsonify({"version": RUNTIME_VERSION}), 200 +@restapi_bp.route("/capabilities", methods=["GET"]) +def restapi_capabilities(): + """Return what this runtime is and what it requires of an editor. + + Unauthenticated, like ``/version`` — the editor calls this before + login to decide whether it may talk to this device at all. + + ``minEditorVersion`` is the oldest editor this runtime accepts + programs from. The runtime only ADVERTISES it: the editor compares + the value against its own version and refuses to upload. Nothing on + the upload path enforces it, so shipping a new runtime can never lock + out an editor already installed in the field (DOPE-448). + + Editors that predate this endpoint get a 404 and fall back to + ``/version``; they simply see no editor floor, which is exactly the + behaviour they had before. + --- + tags: + - Runtime + responses: + 200: + description: Runtime capabilities retrieved + schema: + type: object + properties: + runtimeVersion: + type: string + description: Runtime version string (GitHub release tag) + minEditorVersion: + type: string + description: Oldest OpenPLC Editor version this runtime accepts programs from + """ + return ( + jsonify( + { + "runtimeVersion": RUNTIME_VERSION, + "minEditorVersion": MIN_EDITOR_VERSION, + } + ), + 200, + ) + + jwt = JWTManager(app_restapi) db = SQLAlchemy(app_restapi) diff --git a/webserver/version.py b/webserver/version.py index c96647ce..fc1c2b45 100644 --- a/webserver/version.py +++ b/webserver/version.py @@ -42,6 +42,24 @@ import os from pathlib import Path +# Oldest OpenPLC Editor this runtime accepts programs from, published at +# ``GET /api/capabilities`` as ``minEditorVersion``. +# +# The EDITOR is what compares this value against its own version and refuses +# to upload — the runtime only advertises it (see DOPE-448). That is +# deliberate: an editor already installed in the field can never be locked out +# by a runtime release, because nothing on the upload path enforces this. +# +# Raise this ONLY when an older editor genuinely produces a bundle this +# runtime would mis-compile — a changed file layout, a renamed generated +# artefact, a conf the compile step can no longer read. It is NOT a build +# counter: bumping it for a release that merely "changed something" locks out +# working editors for no reason. When in doubt, leave it alone. +# +# 4.1.0 is the floor because the STruC++ compile pipeline landed there; the +# 4.0.x editors emitted MatIEC artefacts this runtime cannot build at all. +MIN_EDITOR_VERSION = "4.1.0" + def _resolve_runtime_version() -> str: # 1. Env var (CI Docker build-arg path).