Skip to content

opcua: make read-only a property of the build, not a setting - #657

Draft
bburda wants to merge 12 commits into
mainfrom
feat/opcua-read-only-build
Draft

opcua: make read-only a property of the build, not a setting#657
bburda wants to merge 12 commits into
mainfrom
feat/opcua-read-only-build

Conversation

@bburda

@bburda bburda commented Sep 6, 2026

Copy link
Copy Markdown
Collaborator

Pull Request

Summary

Read-only was a convention the OPC UA plugin followed, not a property of what shipped. OpcuaClient::write_value was in every build, DataProvider::write_data and the x-plc-operations route reached it, config-less discovery marked a point writable from the server's CurrentWrite bit, and the alarm acknowledge and confirm operations changed condition state on the controller. A configuration value cannot answer the question a plant security review asks, which is whether this gateway can change a controller. Only the build can, and only an inspection of the built object can prove the answer.

MEDKIT_OPCUA_READ_ONLY, a CMake option, default ON. In the read-only build the value-write path, the alarm acknowledge/confirm path and the x-plc-operations route are compiled out. writable is never inferred from the server and never honoured from a node map; a map that asks for it gets one startup warning naming the build property. The provider methods still exist, because the gateway calls them through its provider interfaces, and they refuse with x-medkit-plugin-error and a message naming the property. ConditionRefresh stays: it asks the server to replay the conditions it already holds and changes nothing on the controller, and without it a restart loses the active fault set. A write-capable build is selected explicitly with -DMEDKIT_OPCUA_READ_ONLY=OFF and exists for development.

The object carries no write path, and CI checks the object, not the flag. test_opcua_build_variant runs nm on the built plugin and asserts, for the variant the build declared: the write markers absent (OpcuaClient::write_value, handle_plc_operations, call_condition_method, the open62541pp write templates and services), the read markers present as a positive control so an empty or wrong object fails, no UA_ or opcua:: symbol in the dynamic export table, the six plugin entry points exported, and none of open62541's UA_*_write* primitives anywhere in the object. The last two hold because the plugin now links with --exclude-libs,ALL and --gc-sections, with the section flags applied to open62541's object libraries: upstream sets them only under Release, and a default-type build previously kept twelve write primitives as local code. A companion self-check test links a module that exports __UA_Client_writeAttribute alongside the six entry points and requires the export rule to reject it.

Both variants are exercised against a real server. test_opcua_read_only drives a gateway with the plugin against the in-tree test server in whichever variant was built: the read-only build reports every point writable: false whatever the server and the map say, advertises no x-plc-operations and no acknowledge/confirm, refuses writes and condition operations with the vendor code, and keeps doing so after the server restarts and the tree is re-walked; the write-capable build writes, the value reaches the server, and an acknowledge acts on a live condition the fixture raised. The OpenPLC docker job became a two-leg matrix: the default image proves the read-only contract on the PLC (refusals, and both tags unchanged on re-read), the write-capable image proves writes. A Jazzy job builds and tests the write-capable variant so that path cannot rot.


Issue


Type

  • Bug fix
  • New feature or tests
  • Breaking change
  • Documentation only

Breaking for a deployment that wrote to a controller through this plugin: the default build refuses those writes. Rebuild with -DMEDKIT_OPCUA_READ_ONLY=OFF to keep them.


Testing

  • Read-only variant, Release: 434 tests, 0 failures. Write-capable variant: 438 tests, 0 failures. Lint on the package clean, zero compiler warnings in our sources, clang-tidy zero findings in changed hunks.
  • The inspection test fails on the read-only object when told to expect write-capable, and on the write-capable object when told to expect read-only, on every marker. Measured in Release and in the default build type.
  • The integration test forced to the wrong variant fails every variant-specific expectation.
  • nm -DC on the read-only object: 115 dynamic exports, none from open62541 or open62541pp, the six entry points present.
  • The OpenPLC script was run locally against a gateway backed by the in-tree fixture with the OpenPLC entity and tag names; every assertion added or moved passes in both legs, and the two tags the fixture does not have are what the CI job with the real PLC image covers.
  • Sphinx: no new warnings.

Checklist

  • Breaking changes are clearly described (and announced in docs / changelog if needed)
  • Tests were added or updated if needed
  • Docs were updated if behavior or public API changed

Four of the five protocol plugins cannot write to their device at all. OPC UA
can, and config-less discovery marks a point writable straight from the
server's CurrentWrite bit. A plant asking whether a box can change a controller
cannot be answered by a configuration value, so the answer is carried by the
binary instead.

MEDKIT_OPCUA_READ_ONLY is a CMake cache option, default ON. With it on,
OpcuaClient::write_value, the open62541pp Write service templates it
instantiates, the vendor route handler and the value-coercion helper are never
compiled; the node map's writable: true is ignored with one startup warning;
the address-space walk does not consult CurrentWrite whatever infer_writable
says; no entity registers x-plc-operations and no set_<name> operation is
listed; and write_data plus the value-write half of execute_operation refuse
with 403 before any node lookup, naming the build property in the message.
Alarm acknowledge/confirm are Part 9 condition method calls rather than value
writes and are unchanged.

The acceptance is an inspection of the built object: test_opcua_build_variant
runs nm over the plugin .so in both variants with opposite expectations, so a
symbol list that stopped matching anything fails in the write-capable build
instead of passing everywhere. Its markers are chosen to discriminate in an
optimized build, which rules out the open62541pp service layer underneath the
write templates - it is inlined away at -O2 and would prove nothing in the
build CI produces. test_opcua_read_only drives a live gateway against the
in-tree fixture server over HTTP, sweeping writable true/false/absent in the
map and infer_writable true/false/absent on the walk, and re-checks after a
reconnect re-walks the address space.
The package README gains a section on MEDKIT_OPCUA_READ_ONLY with both build
commands and what each variant does, and the design doc gains the reasoning and
the enforcement points. Two existing claims were wrong and are corrected: the
auto_browse section said every auto-discovered point loads read-only, which
stopped being true when infer_writable arrived defaulting to true, and
infer_writable was undocumented despite being a live knob accepted only in the
ROS-param form.
Nothing else in CI configures MEDKIT_OPCUA_READ_ONLY=OFF, so the write path and
its tests would rot behind an #if until someone needed them. The job is also
the control for the build inspection: test_opcua_build_variant asserts the
write symbols are present here, which keeps its symbol list from decaying into
one that matches nothing and passes everywhere.

The flag is scoped to the package that defines it. Passed to the whole
--packages-up-to chain, CMake reports it as a manually-specified variable
nobody used in each of the other ten packages, and that stderr would hide a
real one.
-fvisibility=hidden covers the sources compiled into the module but not the
static archives it links, so the read-only object still carried - and exported -
open62541's own __UA_Client_writeAttribute and the rest of the UA_Client_write*
family. No route reached them, but anyone holding the .so could dlsym one and
drive a controller with it, which makes the package's "no symbol able to put a
value on the wire" claim untrue as written.

-Wl,--exclude-libs,ALL takes the archives out of the export table and
-ffunction-sections -fdata-sections plus -Wl,--gc-sections let the linker drop
what nothing references once the C++ write path is gone. Measured on the Release
objects: the write family goes from 9 exported and present to 0 present in the
read-only build, and the module's dynamic exports fall from 689 to 115 - the six
plugin entry points plus C++ vague-linkage symbols, with nothing from the OPC-UA
stack in either variant.

The inspection test gains two markers that survive optimization
(opcua::services::write and services::writeAttribute<Client>), an assertion that
neither variant exports OPC-UA machinery, and an assertion that the six entry
points the gateway resolves are still exported - a link-time change that hid
those would otherwise build and pass every symbol check while failing to load.
The UA_Client_write* family is deliberately not a marker: it is now absent from
both objects and so discriminates nothing, and the script says so where the
exclusion is written.

The README claim is narrowed to what is enforced: no code able to issue a Write
and no export to reach the library through, while open62541's generic service
dispatcher and the type descriptors its UA_TYPES table pins do remain inside the
object, unexported and reachable from no route.
…ng writes

The OpenPLC docker suite POSTed set_pump_speed and set_valve_position and
asserted success, while Dockerfile.gateway built the plugin with defaults - now
read-only, where that route is not registered at all. The job went red on this
branch, and skipping the write section would have left the shipped image
unexercised on the one question the branch is about.

The image takes a MEDKIT_OPCUA_READ_ONLY build arg and the suite takes a
matching MEDKIT_OPCUA_VARIANT, both defaulting to read-only, and the OpenPLC job
runs a leg for each. Against the default image the suite now proves the contract
on a real PLC: no x-plc-operations capability and no set_* operation advertised,
the vendor route answering 404, the SOVD write refused with the vendor code and
a message naming the build property, and both tags reading back unchanged
afterwards. The write-success expectations are unchanged and move to the
write-capable leg, which also re-reads pump_speed off the PLC. Every existing
read, status and error-handling case stays as it was, in both legs.

start.sh and test_all.sh take the same variable, so a local session can bring up
either image without editing anything.
The export rule matched the prefix "UA_", so open62541's internal entry points -
spelled __UA_Client_writeAttribute, __UA_Client_Service - walked straight past
it: a module exporting one of those alongside the six plugin entry points was
reported as exporting nothing from the OPC-UA stack. The rule is now a regex
that makes the leading underscores optional, and a self-check ctest links
exactly such a module and requires the export rule to reject it, next to
name-level cases for both patterns. Against the old prefix rule that self-check
fails on four counts, so it discriminates.

The section flags reached no compilation. Upstream compiles its C sources in the
open62541-object and open62541-plugins OBJECT libraries and assembles open62541
from $<TARGET_OBJECTS:...>, so options set on open62541 apply to nothing; the
flags are now set on the object libraries, and flags.make shows them there. This
mattered only outside Release, because upstream adds the same two flags itself
under Release and MinSizeRel: measured on the read-only object, the
_*UA_(Client|Server)_write* family was 12 in a default-type build and 0 in
Release, and is 0 in both now. The inspection asserts that family is absent from
a read-only object outright, as an invariant rather than a variant marker - it is
absent from the write-capable object too, so it separates nothing between builds,
but its presence means the archive member was pulled back in.
A method call that changes alarm state on the server is a write to the
controller, and the rule is that a read-only build forbids every write path,
not only value writes. Acknowledge and Confirm were left in on the reading that
Part 9 condition interactions are not value writes; that reading is overruled.

The Part 9 Acknowledge (i=9111) and Confirm (i=9113) calls now go through
OpcuaClient::call_condition_method, which is compiled only in a write-capable
build, so the read-only object carries no entry point that can issue one -
measured 0 against 2 in Release and 0 against 1 in the default build type, and
it joins the write markers. list_operations offers neither operation on an
event-alarm entity, and execute_operation refuses both by name, before any
condition lookup, with the vendor code and a message naming the build property.

ConditionRefresh keeps riding on the generic call_method, which stays in both
variants: Part 9 5.5.7 makes it a request for the server to replay conditions it
already holds, it changes nothing on the controller, and without it a restart
loses the active fault set. That is why the guard sits on the condition-method
entry point rather than on call_method, and why neither call_method nor
opcua::services::call is a write marker - measured, both are in either variant.

The integration test grows a leg per variant against a condition the in-tree
fixture really raises: read-only offers neither operation and refuses the POST
with the vendor code; write-capable offers both and the acknowledge reaches a
live ConditionId on the server.
@bburda bburda self-assigned this Sep 6, 2026
…ite one

The AlarmConditionType job POSTs acknowledge_fault and confirm_fault through
SOVD, and its image is built from Dockerfile.gateway defaults - now read-only,
where both are refused. It went red on this branch for the same reason the
OpenPLC job did: that job became a matrix and this one did not.

It takes the same MEDKIT_OPCUA_VARIANT the OpenPLC suite takes and derives the
image build arg from it, so image and expectations cannot drift. The
write-capable leg is the original sequence unchanged. The read-only leg asserts
neither operation is advertised, that both POSTs come back 403 with
x-medkit-plugin-error and a message naming the build property, and that the
condition was not acknowledged - then drives the same ack, latch, confirm,
clear lifecycle from the server's own CLI so every downstream expectation still
runs on that image.

Proving the acknowledge did not land needs a consequence, not a STATE line: the
fixture prints those only from its stdin CLI, so a condition acknowledged over
OPC-UA is indistinguishable from one that was not. The leg therefore latches
the condition and asserts the fault is still raised - had the calls landed, the
latch would have cleared it - and then clears it through the CLI, which is what
makes the negative meaningful rather than a symptom of a stalled pipeline.

Also replaces the loop variables shellcheck reports as unused in this file
(SC2034), which the pre-commit hook reaches now that the file is touched.
Three comments narrated the history of the check rather than the property it
enforces, and two of them named a process that has no place in a source file.
Rewritten as present-tense statements: what the marker list covers and why the
open62541 primitives are an absence invariant rather than a marker, and what the
self-check's linked object proves about the export rule.
Two things the read-only build got wrong about what it says.

The refusal answered 403. SOVD defines 403 once, in auth.rst, as a valid token
with insufficient permissions - the one reading that is actively wrong here,
because no credential and no role reaches a write path that is not in the
binary. The catalogue's idiom for an operation the entity does not support is
501: faults.rst uses it for fault deletion, and data.rst, subscriptions.rst and
triggers.rst all spell "not supported" that way. The three read-only refusals
now return 501 through one named constant that carries the reason. A
write-capable build keeps its codes, including the 403 it returns when the
server itself denies access - which is what 403 is for.

The infer_writable warning fired on the default. The setting defaults to true,
so every read-only deployment with auto_browse enabled was told a key it never
wrote was being ignored, and the node-map spelling reached the generic
unknown-key warning instead. The config now records where an explicit value came
from, the node-map block parses the key like the parameter does, and the warning
fires once, only for a value someone set to true, naming the source so it can be
found. Four unit tests pin both spellings, present and absent, and the
integration sweep asserts the log line appears for an explicit true and does not
appear when the key is left alone.
…lly absent

The image is what ships, and Dockerfile.gateway builds the plugin with
BUILD_TESTING=OFF, so the inspection ctest does not exist inside either
container and no docker script ran nm. Both docker jobs now pull the object out
of the image they just built and run the inspection on the runner with the
expectation their leg declares - the read-only legs are the proof, the
write-capable legs the control. Without it a build-arg or Dockerfile drift that
shipped the wrong variant would have been caught by behaviour, not by the object.

The docs claimed the object contains no code that can issue an OPC UA Write.
That is false as written: open62541 is one static library, so the read path
leaves behind the generic __UA_Client_Service dispatcher, 23 binary encoders,
and the UA_TYPES descriptors the table pins as a whole - measured on the
read-only object, WriteRequest, WriteValue, WriteResponse, AddNodes,
DeleteNodes, AddReferences, SetMonitoringMode, SetPublishingMode and
TransferSubscriptions are all still there, HistoryUpdate is not. Both documents
now enumerate what is absent, each name asserted by nm, and what remains with
the reason no route reaches it: descriptors are data, nothing composes those
requests, nothing is exported, and a read-only build registers three GET routes.
Subscription creation and ConditionRefresh are named as deliberately kept, since
they change no controller data and the read path needs them.
Every image the OPC-UA workflow builds begins with an unauthenticated network
read that nothing of ours has yet touched: apt-get update against the
distribution mirrors, and in the gateway image rosdep update against
raw.githubusercontent.com. A reset connection there fails the whole build, and
the failure names a URL rather than anything about the change being tested.

Both commands now run through a three-attempt wrapper, five seconds apart, in
Dockerfile.gateway, docker/openplc/Dockerfile and
docker/test_alarm_server/Dockerfile. Each attempt's own stderr passes through,
so a genuine failure still ends the build with its own error text after the
third try rather than being swallowed.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Make read-only a property of the OPC UA plugin build, not a setting

1 participant