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
26 changes: 26 additions & 0 deletions .github/workflows/docker-publish-multiarch.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,32 @@ jobs:
# Push each arch by digest; the merge job creates and tags the manifest list.
outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }},push-by-digest=true,name-canonical=true,push=true

# The artifact inspected here is the digest the step above pushed,
# which is the one the merge job stitches into the manifest list and
# tags: it is pulled back from the registry by that digest, so no
# cache hit or rebuild sits between what is checked and what is
# published. The OPC-UA plugin the image carries must have no
# controller write path, and the symbol table of the .so inside it
# is what settles that.
#
# The merge job needs this one, so a digest that fails here never
# reaches a tag. It does stay in the registry as an untagged blob,
# reachable only by a digest no tag points at.
- name: Inspect the plugin object in the pushed image
run: |
set -euo pipefail
trap 'rm -f "$RUNNER_TEMP/plugin-from-image.so"' EXIT
img=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }}
docker pull "$img"
so=$(docker run --rm --entrypoint sh "$img" -c \
'find /home/medkit/ws/install -name libros2_medkit_opcua_plugin.so | head -1')
[ -n "$so" ] || { echo "no OPC-UA plugin object in $img" >&2; exit 1; }
cid=$(docker create "$img")
docker cp "$cid:$so" "$RUNNER_TEMP/plugin-from-image.so"
docker rm -v "$cid" >/dev/null
python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \
"$RUNNER_TEMP/plugin-from-image.so" --expect read-only

- name: Export digest
run: |
mkdir -p /tmp/digests
Expand Down
45 changes: 42 additions & 3 deletions .github/workflows/docker-publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ jobs:
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}

- name: Build and push Docker image
# Pushed by digest, under no tag. The image exporter is what builds
# the provenance attestation that travels with the image, and
# nothing tags this digest until the tag step below runs, so an
# image that fails the inspection is never named.
- name: Build and push by digest
id: build
uses: docker/build-push-action@v6
with:
context: .
push: true
build-args: ROS_DISTRO=${{ matrix.ros_distro }}
tags: ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest
cache-from: type=gha,scope=${{ matrix.ros_distro }}
# mode=min, not max: max exports every intermediate build
# stage, which for three distros held ~3.1 GB of the repo's
Expand All @@ -62,3 +65,39 @@ jobs:
# needs.
cache-to: type=gha,mode=min,scope=${{ matrix.ros_distro }}
platforms: linux/amd64
# Push by digest; the tag step below names what passes the
# inspection.
outputs: type=image,name=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }},push-by-digest=true,name-canonical=true,push=true

# The artifact inspected here is the digest the step above pushed,
# pulled back from the registry by that digest, and it is the same
# digest the tag step below points :latest at - no rebuild and no
# cache hit sits between what is checked and what is published. The
# OPC-UA plugin the image carries must have no controller write
# path, and the symbol table of the .so inside it is what settles
# that.
#
# A digest that fails here is never tagged. It stays in the registry
# as an untagged blob, reachable only by a digest no tag points at.
- name: Inspect the plugin object in the pushed image
run: |
set -euo pipefail
trap 'rm -f "$RUNNER_TEMP/plugin-from-image.so"' EXIT
img=${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }}
docker pull "$img"
so=$(docker run --rm --entrypoint sh "$img" -c \
'find /home/medkit/ws/install -name libros2_medkit_opcua_plugin.so | head -1')
[ -n "$so" ] || { echo "no OPC-UA plugin object in $img" >&2; exit 1; }
cid=$(docker create "$img")
docker cp "$cid:$so" "$RUNNER_TEMP/plugin-from-image.so"
docker rm -v "$cid" >/dev/null
python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \
"$RUNNER_TEMP/plugin-from-image.so" --expect read-only

# :latest is created from the inspected digest, so the tag resolves
# to the artifact the step above read, attestation manifest and all.
- name: Tag the inspected digest
run: |
docker buildx imagetools create \
-t ${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}:latest \
${{ env.REGISTRY }}/${{ github.repository_owner }}/ros2_medkit-${{ matrix.ros_distro }}@${{ steps.build.outputs.digest }}
148 changes: 146 additions & 2 deletions .github/workflows/opcua-plugin.yml
Original file line number Diff line number Diff line change
Expand Up @@ -125,9 +125,21 @@ jobs:
run: colcon test-result --verbose

integration:
name: Integration (OpenPLC)
name: Integration (OpenPLC, ${{ matrix.variant }})
# Both write surfaces are exercised against the same real PLC. The
# read-only leg is the shipped image: it proves the refusal, its vendor
# code, and that the tag on the PLC did not move. The write-capable leg
# keeps proving that a write reaches OpenPLC and reads back.
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
include:
- variant: read-only
read_only: 'ON'
- variant: write-capable
read_only: 'OFF'
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -141,9 +153,26 @@ jobs:
- name: Build gateway + OPC-UA plugin image
run: |
docker build \
--build-arg MEDKIT_OPCUA_READ_ONLY=${{ matrix.read_only }} \
-f src/ros2_medkit_plugins/ros2_medkit_opcua/docker/Dockerfile.gateway \
-t gateway-opcua .

- name: Inspect the plugin object inside the built image
# The image is what ships, and Dockerfile.gateway builds it with
Comment thread
bburda marked this conversation as resolved.
# BUILD_TESTING=OFF, so the inspection ctest does not exist inside the
# container. Pull the object out and run the same check on the runner:
# without this the image is only proven by its behaviour, and a
# build-arg or Dockerfile drift that shipped the wrong variant would be
# caught by the integration assertions rather than by the inspection.
run: |
cid=$(docker create gateway-opcua)
docker cp "$cid:$(docker run --rm --entrypoint sh gateway-opcua -c \
"find /root/ws/install -name libros2_medkit_opcua_plugin.so | head -1")" \
./plugin-from-image.so
docker rm -v "$cid" >/dev/null
python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \
./plugin-from-image.so --expect ${{ matrix.variant }}

- name: Start OpenPLC
timeout-minutes: 3
run: |
Expand Down Expand Up @@ -212,6 +241,8 @@ jobs:
docker logs gateway 2>&1 | tail -10

- name: Run integration tests
env:
MEDKIT_OPCUA_VARIANT: ${{ matrix.variant }}
run: bash src/ros2_medkit_plugins/ros2_medkit_opcua/docker/scripts/run_integration_tests.sh

- name: Dump gateway logs on failure
Expand All @@ -229,13 +260,24 @@ jobs:
docker network rm plc-demo 2>/dev/null || true

integration-alarms:
name: Integration (AlarmConditionType)
name: Integration (AlarmConditionType, ${{ matrix.variant }})
# Issue #386: tests the native OPC-UA AlarmCondition subscription bridge
# against the test_alarm_server fixture (open62541 with FULL ns0 + alarms
# ON). Independent of the OpenPLC threshold-mode integration above; runs
# in parallel.
#
# Both write surfaces, like the OpenPLC job: acknowledging a condition
# changes its state on the controller, so the read-only leg asserts the
# refusal and that the condition stays unacknowledged on the server, and the
# write-capable leg asserts the acknowledge reaches it. The script builds the
# gateway image itself and derives the build arg from the same variable, so
# the image and the expectations cannot drift apart.
runs-on: ubuntu-latest
timeout-minutes: 30
strategy:
fail-fast: false
matrix:
variant: [read-only, write-capable]
steps:
- name: Checkout repository
uses: actions/checkout@v4
Expand All @@ -247,8 +289,22 @@ jobs:
pip3 install --break-system-packages asyncua

- name: Run alarm integration suite
env:
MEDKIT_OPCUA_VARIANT: ${{ matrix.variant }}
run: bash src/ros2_medkit_plugins/ros2_medkit_opcua/docker/scripts/run_alarm_tests.sh

- name: Inspect the plugin object inside the built image
# Same reason as the OpenPLC job. This suite builds its own image, so
# the step runs after it; the tag is the one run_alarm_tests.sh builds.
run: |
cid=$(docker create gateway-opcua:alarm-test)
docker cp "$cid:$(docker run --rm --entrypoint sh gateway-opcua:alarm-test -c \
"find /root/ws/install -name libros2_medkit_opcua_plugin.so | head -1")" \
./plugin-from-image.so
docker rm -v "$cid" >/dev/null
python3 src/ros2_medkit_plugins/ros2_medkit_opcua/test/inspect_build_variant.py \
./plugin-from-image.so --expect ${{ matrix.variant }}

- name: Dump container logs on failure
if: failure()
run: |
Expand Down Expand Up @@ -342,3 +398,91 @@ jobs:
- name: Show test results
if: always()
run: colcon test-result --verbose

write-capable-build:
# The shipped plugin is read-only; MEDKIT_OPCUA_READ_ONLY=OFF is the
# development variant. Nothing else in CI configures it, so without this job
# the write path and its tests would rot unnoticed behind an #if until
# someone needed them. It is also the control for the build-inspection test:
# test_opcua_build_variant asserts the write symbols are PRESENT here, which
# is what keeps its symbol list from decaying into one that matches nothing
# and passes everywhere.
name: Write-capable build (jazzy)
runs-on: ubuntu-latest
container:
image: ubuntu:noble
timeout-minutes: 60
defaults:
run:
shell: bash
steps:
- name: Install Git
run: |
apt-get update
apt-get install -y git

- name: Checkout repository
uses: actions/checkout@v4

- name: Pre-install ROS 2 apt source
uses: ./.github/actions/ros-apt-source

- name: Set up ROS 2 Jazzy
uses: ros-tooling/setup-ros@v0.7
with:
required-ros-distributions: jazzy

- name: Install ccache
run: apt-get install -y ccache

- name: Cache ccache
uses: actions/cache@v4
with:
path: /root/.cache/ccache
key: ccache-opcua-write-capable-${{ github.sha }}
restore-keys: |
ccache-opcua-write-capable-

- name: Install dependencies
run: |
apt-get update
apt-get install -y ros-jazzy-test-msgs libyaml-cpp-dev libssl-dev
source /opt/ros/jazzy/setup.bash
rosdep update
rosdep install --from-paths src --ignore-src -y \
--skip-keys='nav2_msgs ament_cmake_clang_format ament_cmake_clang_tidy'

- name: Build ros2_medkit_opcua write-capable
env:
CCACHE_DIR: /root/.cache/ccache
CCACHE_MAXSIZE: 500M
CCACHE_SLOPPINESS: pch_defines,time_macros
run: |
source /opt/ros/jazzy/setup.bash
# Two passes so MEDKIT_OPCUA_READ_ONLY reaches only the package that
# defines it. Passed to the whole chain instead, CMake reports it as a
# manually-specified variable nobody used in each of the other ten
# packages, and the resulting stderr would hide any real one.
colcon build --symlink-install \
--packages-up-to ros2_medkit_opcua --packages-skip ros2_medkit_opcua \
--cmake-args -DCMAKE_BUILD_TYPE=Release \
--event-handlers console_direct+
colcon build --symlink-install \
--packages-select ros2_medkit_opcua \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DMEDKIT_OPCUA_READ_ONLY=OFF \
--event-handlers console_direct+
ccache -s

- name: Run tests
timeout-minutes: 20
run: |
source /opt/ros/jazzy/setup.bash
source install/setup.bash
colcon test --return-code-on-test-failure \
--packages-select ros2_medkit_opcua \
--ctest-args -LE linter \
--event-handlers console_direct+

- name: Show test results
if: always()
run: colcon test-result --verbose
25 changes: 24 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,23 @@ COPY src/ros2_medkit_plugins/ ${COLCON_WS}/src/ros2_medkit_plugins/
# This was previously masked by Docker layer cache hits on CI; cold builds
# always failed.
#
# The OPC-UA plugin in this image carries no controller write path:
# MEDKIT_OPCUA_READ_ONLY=ON is stated on the colcon line, because this image is
# what a diagnostic box runs on a plant network and the CMake option's default
# is not a property anything can check. The flag gets a colcon pass of its own
# because only one package declares it: passed to the whole workspace, every
# other package reports it as a manually-specified variable nobody used, and
# that stderr would bury a real one. The build type is stated on the same line:
# a fresh configure of this package otherwise takes whatever default its
# FetchContent dependencies put in the cache, and Release is what the plugin's
# other build sites compile with.
#
# The test at the end guards the split. colcon accepts an unknown name in
# --packages-skip and in --packages-select with a warning and exit 0, so a
# renamed or moved package would leave the first pass building the plugin at the
# option's default and the second pass building nothing, with this RUN still
# succeeding. The object has to exist where the second pass installs it.
#
# rosbag2_storage_mcap stays skip-keyed here even though fault_manager now
# exec_depends on it: this stage compiles with -DBUILD_TESTING=OFF against
# rosbag2_cpp/rosbag2_storage (the plugin interface, already resolvable
Expand All @@ -97,7 +114,13 @@ RUN bash -c "source /opt/ros/${ROS_DISTRO}/setup.bash && \
rosdep install --from-paths src --ignore-src -r -y \
--skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy ament_cmake_flake8 test_msgs sqlite3 libcpp-httplib-dev rosbag2_storage_mcap' && \
rm -rf /var/lib/apt/lists/* && \
colcon build --cmake-args -DBUILD_TESTING=OFF"
colcon build --cmake-args -DBUILD_TESTING=OFF \
--packages-skip ros2_medkit_opcua && \
source install/setup.bash && \
colcon build --packages-select ros2_medkit_opcua \
--cmake-args -DBUILD_TESTING=OFF -DMEDKIT_OPCUA_READ_ONLY=ON \
-DCMAKE_BUILD_TYPE=Release && \
test -f install/ros2_medkit_opcua/lib/ros2_medkit_opcua/libros2_medkit_opcua_plugin.so"

# ============================================================================
# Stage 2: Runtime
Expand Down
Loading
Loading