Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
4cc3700
feat(demos/ota): scaffold pack_artifact CLI
bburda Apr 26, 2026
0a3ddaf
feat(demos/ota): pack_artifact argparse + dispatcher signature
bburda Apr 26, 2026
3a5b41e
feat(demos/ota): build SOVD-shaped catalog entry
bburda Apr 26, 2026
3d22b16
feat(demos/ota): merge_catalog with id-based replace
bburda Apr 26, 2026
876b960
feat(demos/ota): tarball creation from install dir
bburda Apr 26, 2026
f86c765
feat(demos/ota): pack_artifact end-to-end run() with kind dispatch
bburda Apr 26, 2026
161b707
fix(demos/ota): version default to 0.0.0 + cleanup unused symbols + p…
bburda Apr 26, 2026
398842b
test(demos/ota): cover colcon_build, install e2e, version-required guard
bburda Apr 26, 2026
216acc7
feat(demos/ota): ota_update_server scaffold
bburda Apr 26, 2026
e42af7e
test(ota_server): /catalog endpoint coverage
bburda Apr 26, 2026
ab10c29
test(ota_server): /artifacts endpoint + path traversal guard
bburda Apr 26, 2026
9b71e5a
feat(demos/ota): ota_update_server Dockerfile
bburda Apr 26, 2026
1f838de
chore(ota_server): pyright config to silence venv import warnings
bburda Apr 26, 2026
653902b
fix(ota_server): mark /artifacts route response_class=FileResponse
bburda Apr 26, 2026
248ef32
feat(demos/ota): broken_lidar node with phantom /scan return
bburda Apr 26, 2026
c8e10eb
feat(demos/ota): fixed_lidar (clean /scan, no phantom)
bburda Apr 26, 2026
a14243b
feat(demos/ota): broken_lidar_legacy do-nothing node (uninstall target)
bburda Apr 26, 2026
01e5fc8
feat(demos/ota): obstacle_classifier_v2 (install target, /scan -> /sa…
bburda Apr 26, 2026
9defc1a
feat(demos/ota): build_artifacts.sh + gitignore generated tarballs
bburda Apr 26, 2026
706c62c
fix(demos/ota): use array for pack_artifact invocation in build script
bburda Apr 26, 2026
1a9f20a
feat(demos/ota): ota_update_plugin C++ gateway plugin
bburda Apr 26, 2026
78816db
fix(ota_plugin): double-fork to avoid zombies, init catalog client in…
bburda Apr 26, 2026
3517bb8
feat(demos/ota): thread x_medkit_replaces_executable for update kind
bburda Apr 26, 2026
3bb6b1b
fix(ota_plugin): honor x_medkit_replaces_executable when killing old …
bburda Apr 26, 2026
f088c7e
feat(demos/ota): docker compose stack + gateway config + entrypoint +…
bburda Apr 26, 2026
2f7d817
fix(ota_plugin): __has_include compat for older gateway updates/ head…
bburda Apr 26, 2026
ec5070f
fix(ota_plugin): cmdline-based pgrep + UpdateProvider C export + runt…
bburda Apr 26, 2026
80e4af1
fix(demos/ota): use SOVD spec field names update_name + x_medkit_version
bburda Apr 26, 2026
8f48af1
test(demos/ota): committable Playwright e2e smoke driving web UI agai…
bburda Apr 26, 2026
a1726c4
feat(demos/ota): run-demo / stop-demo / check-demo / trigger-* scripts
bburda Apr 27, 2026
bd7a54f
test(demos/ota): smoke test + CI job mirroring sensor_diagnostics pat…
bburda Apr 27, 2026
5baa086
fix(demos/ota): rename unused loop var in run-demo to satisfy shellch…
bburda Apr 27, 2026
bf6d540
ci(ota): build artifacts inside ros:jazzy container instead of instal…
bburda Apr 27, 2026
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
72 changes: 72 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -163,3 +163,75 @@ jobs:
if: always()
working-directory: demos/multi_ecu_aggregation
run: docker compose --profile ci down

build-and-test-ota:
needs: lint
runs-on: ubuntu-24.04
steps:
- name: Show triggering source
if: github.event_name == 'repository_dispatch'
run: |
SHA="${{ github.event.client_payload.sha }}"
RUN_URL="${{ github.event.client_payload.run_url }}"
echo "## Triggered by ros2_medkit" >> "$GITHUB_STEP_SUMMARY"
echo "- Commit: \`${SHA:-unknown}\`" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$RUN_URL" ]; then
echo "- Run: [View triggering run]($RUN_URL)" >> "$GITHUB_STEP_SUMMARY"
else
echo "- Run: (URL not provided)" >> "$GITHUB_STEP_SUMMARY"
fi

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

- name: Build artifacts (catalog + tarballs) inside ros:jazzy
working-directory: demos/ota_nav2_sensor_fix
run: |
docker run --rm \
-v "$PWD":/work \
-w /work \
ros:jazzy \
bash -c '
set -eu
apt-get update
apt-get install -y --no-install-recommends \
python3-colcon-common-extensions \
python3-catkin-pkg \
python3-venv \
python3-pip \
build-essential \
cmake \
ros-jazzy-rclcpp \
ros-jazzy-sensor-msgs \
ros-jazzy-visualization-msgs
cd scripts
python3 -m venv .venv
.venv/bin/pip install --upgrade pip
.venv/bin/pip install pytest
cd ..
./scripts/build_artifacts.sh
'
# Restore ownership of files the container created as root.
sudo chown -R "$USER:$USER" .

- name: Build and start OTA demo
working-directory: demos/ota_nav2_sensor_fix
run: docker compose up -d --build

- name: Run smoke tests
run: ./tests/smoke_test_ota.sh

- name: Show gateway logs on failure
if: failure()
working-directory: demos/ota_nav2_sensor_fix
run: docker compose logs gateway --tail=200

- name: Show update server logs on failure
if: failure()
working-directory: demos/ota_nav2_sensor_fix
run: docker compose logs ota_update_server --tail=200

- name: Teardown
if: always()
working-directory: demos/ota_nav2_sensor_fix
run: docker compose down
77 changes: 77 additions & 0 deletions demos/ota_nav2_sensor_fix/Dockerfile.gateway
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
# Copyright 2026 bburda
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Builds the ros2_medkit gateway, the ota_update_plugin, and the four demo
# ROS 2 packages into a single ROS 2 Jazzy image. Plugin loads at gateway
# startup via /etc/ros2_medkit/gateway_config.yaml and the entrypoint also
# launches the broken_lidar demo nodes that get swapped/uninstalled at
# runtime by the plugin.

FROM ros:jazzy AS builder

ARG GATEWAY_REPO=https://github.com/selfpatch/ros2_medkit.git
ARG GATEWAY_REF=main

RUN apt-get update && apt-get install -y --no-install-recommends \
git \
python3-colcon-common-extensions \
python3-rosdep \
build-essential \
cmake \
curl \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*

RUN rosdep init || true
RUN rosdep update --rosdistro=jazzy

WORKDIR /ws/src
RUN git clone --depth=1 --branch ${GATEWAY_REF} ${GATEWAY_REPO} ros2_medkit

# Copy demo packages (broken_lidar, fixed_lidar, broken_lidar_legacy,
# obstacle_classifier_v2) and the OTA plugin from the build context.
COPY ros2_packages /tmp/ros2_packages
RUN cp -r /tmp/ros2_packages/. /ws/src/ && rm -rf /tmp/ros2_packages
COPY ota_update_plugin /ws/src/ota_update_plugin

WORKDIR /ws
# rosdep needs the apt cache populated to install gateway dependencies
# (nlohmann-json3-dev, libcpp-httplib-dev, etc.).
RUN apt-get update
RUN . /opt/ros/jazzy/setup.sh && \
rosdep install --from-paths src --ignore-src -r -y --rosdistro=jazzy && \
colcon build \
--cmake-args -DCMAKE_BUILD_TYPE=Release && \
rm -rf /var/lib/apt/lists/*


FROM ros:jazzy

RUN apt-get update && apt-get install -y --no-install-recommends \
ros-jazzy-rclcpp \
ros-jazzy-rclcpp-lifecycle \
ros-jazzy-sensor-msgs \
ros-jazzy-visualization-msgs \
ros-jazzy-launch-ros \
ros-jazzy-test-msgs \
libcpp-httplib-dev \
libsystemd-dev \
nlohmann-json3-dev \
curl \
procps \
&& rm -rf /var/lib/apt/lists/*

COPY --from=builder /ws/install /ws/install
COPY gateway_config.yaml /etc/ros2_medkit/gateway_config.yaml
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh

ENV ROS_DOMAIN_ID=42

EXPOSE 8080
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
84 changes: 84 additions & 0 deletions demos/ota_nav2_sensor_fix/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
# OTA over SOVD - nav2 sensor fix demo

End-to-end demo: a `ros2_medkit` gateway with a dev-grade OTA plugin that
demonstrates the full Update / Install / Uninstall lifecycle on ROS 2 nodes
without SSH-ing into the robot.

## What this shows

Three things you can do to a ROS 2 robot over the air:

1. **Update** - swap a running sensor node with a fixed version (the
`broken_lidar` -> `fixed_lidar` flip).
2. **Install** - pull and start a new ROS 2 package
(`obstacle_classifier_v2`).
3. **Uninstall** - stop and remove a deprecated package
(`broken_lidar_legacy`).

All three operations are SOVD ISO 17978-3 compliant - the kind is derived
from `updated_components` / `added_components` / `removed_components` in the
update package metadata.

## Quickstart

```bash
# Build artifacts + start gateway, plugin, demo nodes, update server.
./run-demo.sh
```

The first run pulls `ros:jazzy` and builds the gateway from source - takes
~10 minutes. Subsequent runs reuse the layer cache.

In another terminal, drive the demo:

```bash
./check-demo.sh # show registered updates + live process state
./trigger-update.sh # broken_lidar -> fixed_lidar (the headline scene)
./trigger-install.sh # install obstacle_classifier_v2 from scratch
./trigger-uninstall.sh # remove broken_lidar_legacy
./stop-demo.sh # tear down
```

Each trigger script issues SOVD `PUT /updates/{id}/prepare` then `/execute`
and prints the resulting status plus the live process list.

If host port 8080 is taken, override with `OTA_GATEWAY_PORT=8081 ./run-demo.sh`.

Tear down: `docker compose down`.

## Adding a Foxglove visualization

Install the `ros2_medkit_foxglove_extension` (which now ships an Updates
panel - see https://github.com/selfpatch/ros2_medkit_foxglove_extension)
in your local Foxglove Studio, then point it at
`http://localhost:8080/api/v1`. The Updates panel exposes Prepare and
Execute buttons next to each catalog entry.

## Adding nav2 / a sim

This demo intentionally omits a nav2 sim from the compose so the stack stays
small and reliably reproducible. To make the visual story complete:

- Bring up your favourite turtlebot3 sim (`turtlebot3_gazebo`) and point it
at `ROS_DOMAIN_ID=42` to share the DDS namespace with the gateway.
- The broken_lidar node publishes a phantom return on `/scan` ~1m straight
ahead. nav2's costmap will trace it as an obstacle and the planner will
refuse to drive forward. After the update flow, fixed_lidar publishes a
clean scan and the path planner unblocks.

## Disclosures

This is **dev-grade** OTA. Deliberately missing for production:

- No artifact signing or signature verification
- No atomic swap (in-place overwrite)
- No A/B partition rollout
- No fleet-wide staged rollout
- No persistent update state across gateway restarts
- No automated health-gated rollback policy
- No audit log

Perfect for: prototypes, lab robots, internal demos, dev environments.

For production-grade OTA (rollout safety, signing, A/B partitions,
fleet-aware staging), reach out.
2 changes: 2 additions & 0 deletions demos/ota_nav2_sensor_fix/artifacts/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
*.tar.gz
catalog.json
56 changes: 56 additions & 0 deletions demos/ota_nav2_sensor_fix/check-demo.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
#!/bin/bash
# Show the live state of the OTA demo: registered updates, per-update
# status, and the demo node processes the plugin manages inside the
# gateway container.

set -eu

GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}"
API="${GATEWAY_URL}/api/v1"

if ! command -v curl >/dev/null 2>&1; then
echo "curl is required"
exit 1
fi

if ! curl -fsS "${API}/health" >/dev/null 2>&1; then
echo "Gateway not reachable at ${GATEWAY_URL}. Start it with: ./run-demo.sh"
exit 1
fi

JQ_AVAILABLE="false"
if command -v jq >/dev/null 2>&1; then
JQ_AVAILABLE="true"
fi

echo "Gateway: ${GATEWAY_URL}"
echo "Health: $(curl -fsS "${API}/health" | head -c 200)"
echo ""

echo "Registered updates (GET /updates):"
if [[ "$JQ_AVAILABLE" == "true" ]]; then
curl -fsS "${API}/updates" | jq -r '.items[]' | sed 's/^/ /'
else
curl -fsS "${API}/updates"
fi
echo ""

echo "Per-update status (GET /updates/{id}/status):"
if [[ "$JQ_AVAILABLE" == "true" ]]; then
for id in $(curl -fsS "${API}/updates" | jq -r '.items[]'); do
status=$(curl -fsS "${API}/updates/${id}/status" 2>/dev/null || echo '{"status":"<no status>"}')
echo " ${id}: $(echo "$status" | jq -c '{status, progress}')"
done
else
echo " (install jq for detail)"
fi
echo ""

echo "Plugin-managed processes inside gateway container:"
if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then
docker exec ota_demo_gateway pgrep -af \
'broken_lidar_node|fixed_lidar_node|broken_lidar_legacy|obstacle_classifier' \
2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || echo " (none)"
else
echo " ota_demo_gateway container not running"
fi
36 changes: 36 additions & 0 deletions demos/ota_nav2_sensor_fix/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2026 bburda
# Apache 2.0
#
# Two-service stack: the gateway (with ota_update_plugin baked in plus the
# demo nodes the plugin will manage) and the FastAPI artifact server. nav2
# and Foxglove are intentionally out of scope here - see README for how to
# bring your own.

services:
gateway:
image: selfpatch/ota_demo_gateway:dev
build:
context: .
dockerfile: Dockerfile.gateway
container_name: ota_demo_gateway
networks: [otanet]
ports:
- "${OTA_GATEWAY_PORT:-8080}:8080"
environment:
ROS_DOMAIN_ID: 42
depends_on:
- ota_update_server

ota_update_server:
image: selfpatch/ota_update_server:dev
build:
context: .
dockerfile: ota_update_server/Dockerfile
container_name: ota_demo_update_server
networks: [otanet]
ports:
- "9000:9000"

networks:
otanet:
driver: bridge
27 changes: 27 additions & 0 deletions demos/ota_nav2_sensor_fix/entrypoint.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
# Copyright 2026 bburda
# Apache 2.0
#
# Container entrypoint: launches the demo nodes that the OTA plugin will
# manage at runtime, then forks the gateway as PID 1's foreground process.

set -e

# shellcheck disable=SC1091
source /opt/ros/jazzy/setup.bash
# shellcheck disable=SC1091
source /ws/install/setup.bash

# Demo nodes the plugin will swap (broken_lidar -> fixed_lidar) and
# uninstall (broken_lidar_legacy). obstacle_classifier_v2 is installed
# fresh by the demo and not started here.
ros2 run broken_lidar broken_lidar_node &
ros2 run broken_lidar_legacy broken_lidar_legacy &

# Foreground gateway. Pass the config file directly to the gateway_node
# executable (the gateway.launch.py wrapper does not expose a config_file
# argument, so we invoke the executable directly to thread our YAML in).
exec ros2 run ros2_medkit_gateway gateway_node \
--ros-args \
--params-file /etc/ros2_medkit/gateway_config.yaml \
--log-level info
Loading
Loading