Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 13 additions & 1 deletion docs/EDITOR_INTEGRATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand Down
74 changes: 74 additions & 0 deletions tests/pytest/restapi/test_capabilities.py
Original file line number Diff line number Diff line change
@@ -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
45 changes: 44 additions & 1 deletion webserver/restapi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down Expand Up @@ -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)

Expand Down
18 changes: 18 additions & 0 deletions webserver/version.py
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down