Skip to content

feat: nav2 sensor fix OTA demo#58

Open
bburda wants to merge 19 commits into
mainfrom
feat/ota-nav2-sensor-fix
Open

feat: nav2 sensor fix OTA demo#58
bburda wants to merge 19 commits into
mainfrom
feat/ota-nav2-sensor-fix

Conversation

@bburda

@bburda bburda commented Apr 26, 2026

Copy link
Copy Markdown
Contributor

Description

Adds demos/ota_nav2_sensor_fix/ - an end-to-end OTA-over-SOVD demo (gateway plugin + FastAPI artifact server + 4 ROS 2 demo packages) exercising the /updates lifecycle: update a broken lidar node, install a new safety classifier, and uninstall a deprecated package, all over HTTP and spec-compliant.

OTA demo

  • Adds demos/ota_nav2_sensor_fix/ end-to-end OTA demo
  • Bundles a dev-grade ota_update_plugin C++ gateway plugin (UpdateProvider + GatewayPlugin)
  • Update / Install / Uninstall operations derived from SOVD ISO 17978-3 components metadata (updated_components / added_components / removed_components)
  • Minimal FastAPI artifact server + pack_artifact.py CLI for building tarballs and catalog entries
  • Two-service docker-compose.yml (gateway + update server); nav2 / Foxglove are bring-your-own (documented in README)

Out of scope (deliberate, dev-grade positioning)

  • Artifact signing / verification
  • Atomic swap or A/B partition rollout
  • Persistent update state across gateway restarts
  • Fleet-wide staging
  • Audit logging
  • Automated health-gated rollback

Test plan / verification

Unit & integration tests (all clean):

  • pytest -v for pack_artifact.py (16 tests)
  • pytest -v for ota_update_server (5 tests)
  • colcon test for ota_update_plugin (24 GTest cases)
  • All four demo ROS 2 packages build clean under -Wall -Wextra -Wpedantic -Wshadow -Wconversion
  • build_artifacts.sh produces a 3-entry catalog + tarballs end-to-end

End-to-end smoke:

  • Plugin loads and registers as UpdateProvider (gateway logs: "Update backend provided by plugin")
  • Boot poll fetches /catalog and registers all 3 catalog entries
  • Update flow: PUT /updates/fixed_lidar_2_1_0/prepare && /execute kills broken_lidar_node and spawns fixed_lidar_node
  • Install flow: PUT /updates/obstacle_classifier_v2_1_0_0/prepare && /execute swaps files and spawns obstacle_classifier_node
  • Uninstall flow: PUT /updates/broken_lidar_legacy_remove/prepare && /execute returns status: completed and the legacy process is gone
  • tests/smoke_test_ota.sh - 25/25 pass on a fresh stack
  • tests/smoke_test_demo_narrative.sh - 8/8 pass on a fresh stack
  • trigger-update.sh, trigger-install.sh, trigger-uninstall.sh, check-demo.sh, stop-demo.sh exercised end-to-end

Notes

@bburda bburda self-assigned this Apr 29, 2026
@bburda bburda changed the title feat: nav2 sensor fix OTA demo feat: nav2 sensor fix OTA demo + cross-demo script regressions May 26, 2026
@bburda bburda force-pushed the feat/ota-nav2-sensor-fix branch from 39a0925 to fff9581 Compare May 29, 2026 20:00
@bburda bburda changed the title feat: nav2 sensor fix OTA demo + cross-demo script regressions feat: nav2 sensor fix OTA demo May 29, 2026
@bburda bburda force-pushed the feat/ota-nav2-sensor-fix branch from fff9581 to 7a37cc6 Compare May 30, 2026 10:10
…pdate, publish-and-apply a hotfix

Dev-grade OTA demo on the ros2_medkit gateway's ota_update_plugin and the SOVD
/updates resource. An RB-Theron AMR runs Nav2 in an AWS small-warehouse world. A
regressing lidar update (broken_lidar_3_0_0) is auto-applied at boot; a few
metres into a mission the scan sensor develops a stuck sector, Nav2 can no
longer make progress, and navigate_to_pose aborts. Generic log/action-status
bridges surface that as SOVD faults on bt-navigator and controller-server with a
freeze-frame and an MCAP capture - the sensor node never reports itself.

The operator diagnoses over SOVD: downloads the MCAP, runs a suite of
health-check operations (lidar/localization/drivetrain/costmap) to confirm the
lidar is the cause, then publishes the forward hotfix (fixed_lidar_3_0_1) as a
hand-provided update descriptor via POST /updates and applies it (prepare +
execute), and clears the latched faults. A custom nav_to_pose behaviour tree
keeps the abort prompt. Foxglove panels + curl scripts drive the loop; smoke
tests cover the plugin, the SOVD envelope, the publish-then-apply flow, and the
single-publisher /scan remap. Runs on CycloneDDS (FastDDS segfaults
amcl/controller_server on Jazzy).
@bburda bburda force-pushed the feat/ota-nav2-sensor-fix branch from 7a37cc6 to 7a85e20 Compare July 4, 2026 14:40
bburda added 11 commits July 4, 2026 18:05
The default recovery (spin, wait 5s, back-up) made a stuck robot thrash for
20-30s before navigate_to_pose aborted. Strip the recovery RoundRobin down to a
costmap clear (no motion), keep the top-level retry so the goal still aborts, and
shorten the progress-checker window (movement_time_allowance 10 -> 4s). The robot
now drives, stalls on the phantom, and aborts in ~15-19s with no spinning.
Group the health-check app under a new 'Health Checks' function (category
diagnostics) so the differential-diagnosis operations (lidar / localization /
drivetrain / costmap) show up as a first-class functional grouping in the SOVD
entity tree, alongside Fleet Diagnostics.
…e gz race

The gz_ros2_control cold start races the controller spawners; when it loses, the
robot has no odometry/TF and Nav2 aborts every goal instantly. run-demo.sh now
waits for the odom->base_footprint TF to actually flow before reporting the demo
up, and restarts the sim (bounded to 3 tries) if it does not - so one run-demo.sh
hands off a genuinely drive-ready robot instead of leaving it to chance.
… health checks into Fleet Diagnostics

The fault_manager defaults snapshots.rosbag.topics to 'entity', which narrows
the captured bag to the fault source node's subgraph (bt_navigator: /odom /tf
/bond /navigate_to_pose/*) and drops /scan, /cmd_vel and /local_costmap - so the
downloaded MCAP had none of the diagnostic topics and Foxglove playback showed
nothing. Set topics 'explicit' (write exactly include_topics) + qos_match so the
capture records /scan (the phantom), /cmd_vel, /tf and the local costmap. Also
add the health-check app to the Fleet Diagnostics function so its operations
surface there (dropped the redundant standalone function).
…cute

The descriptor shipped automated:false, which the Updates panel surfaces as 'not
automated' and uses to disable the Execute button after a completed Prepare. The
OTA plugin does apply the update itself on execute (kill + respawn), so it is an
automated application - set automated:true. Verified: Prepare alone does NOT
auto-swap (broken stays), Execute swaps to fixed - the two-step operator flow is
preserved.
…abort BT

Add a latched_relay node that re-publishes the latched /robot_description (URDF)
and /tf_static at 2 Hz so the fault_manager's post-hoc volatile rosbag capture
records them - without them a downloaded MCAP had no robot mesh and no static TF
to place /scan against, so playback showed only floating obstacles. Add
/robot_description to the capture include_topics. Also bake the nav2 default
behavior-tree overwrite into Dockerfile.gateway (was only applied at runtime) so
a clean build ships the fast-abort tree.
…hecks operation

The 4 std_srvs/Trigger checks each showed a meaningless auto-generated
'structure_needs_at_least_one_member' request field and needed 4 separate clicks.
Replace them with a single health_check_msgs/RunHealthChecks service: request
bools lidar/localization/drivetrain/costmap (default true) select which checks to
run, response returns ok / checks_run / checks_failed and a per-check report.
One operator operation, meaningful request fields, richer diagnostic output; the
per-check logic and message wording are unchanged.
The relay called declare_parameter('use_sim_time') but rclpy auto-declares that
parameter and the launch already sets it via -p, so the node raised
ParameterAlreadyDeclaredException and died on startup every boot. Rely on the
launch-provided value instead of re-declaring.
…in the MCAP

supports_automated() returned false, so the UI's combined 'Prepare & execute'
reported 'package does not support automatic updates' even though the plugin
applies updates itself on execute - return true. Also capture
/local_costmap/published_footprint so MCAP playback shows the robot's outline and
pose; the URDF mesh cannot render on replay (Foxglove resolves package:// meshes
via the live foxglove_bridge asset service, absent in a bag), so the footprint +
/tf frames are how you see where the robot is in the recording.
…pose freshness

AMCL only republishes /amcl_pose on a motion-triggered filter update, so once the
robot reaches its goal and sits still the pose goes stale even though AMCL is
still localized (it keeps broadcasting map->odom). The freshness check then
reported 'AMCL pose stale/absent' after the OTA fix, collapsing the whole
post-fix 'all healthy' result on a false positive. Judge the cached pose's xy
covariance instead - small = converged, regardless of age; a real divergence
still trips the bound.
… medkit workspace

The gateway image ran a bare 'colcon build' over the full 15-package ros2_medkit
clone - including ros2_medkit_opcua (open62541pp, very slow to compile),
integration_tests, and the discovery beacons the demo never loads (it only loads
ota_update_plugin). Restrict the build to --packages-up-to the four medkit
packages the demo actually runs (gateway, fault_manager, log/action-status
bridges) plus the demo's own packages, so their deps come in transitively and
everything else is skipped.
@bburda bburda marked this pull request as ready for review July 6, 2026 13:33
Copilot AI review requested due to automatic review settings July 6, 2026 13:33

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR adds a new end-to-end “OTA over SOVD” demo (demos/ota_nav2_sensor_fix/) that exercises the /updates lifecycle against a live ros2_medkit gateway, including a dev-grade OTA plugin, an artifact server, and ROS 2 demo packages used to demonstrate a forward hotfix of a regressed lidar node.

Changes:

  • Introduces a complete OTA demo stack (gateway image + C++ OTA plugin + FastAPI artifact server + ROS 2 demo packages + operator scripts).
  • Adds automated validation (new OTA smoke tests + CI jobs) to cover /updates behavior, spec-shaped payloads, and process swapping.
  • Documents the new demo in the repository README and includes third-party notice pinning for build-fetched assets.

Reviewed changes

Copilot reviewed 75 out of 77 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
tests/smoke_test_ota.sh New host-driven smoke test for the OTA demo’s /updates and process swap flow
tests/smoke_lib.sh Makes smoke harness exit code reflect recorded failures (and preserve crash exit codes)
README.md Adds the new OTA demo and includes it in the smoke test list
demos/ota_nav2_sensor_fix/updates/README.md Documents manual publishing of updates via POST /updates
demos/ota_nav2_sensor_fix/updates/fixed_lidar_3_0_1.json Ships the hotfix update descriptor used by publish/apply scripts
demos/ota_nav2_sensor_fix/trigger-bad-update.sh Script to re-apply the regressing update via prepare/execute
demos/ota_nav2_sensor_fix/THIRD_PARTY_NOTICES.md Documents pinned upstream assets fetched at image build time
demos/ota_nav2_sensor_fix/stop-demo.sh Demo teardown helper (compose down + optional cleanup)
demos/ota_nav2_sensor_fix/send-goal.sh Helper to publish a Nav2 goal inside the gateway container
demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py Pytest coverage for pack_artifact.py
demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json Pyright config for demo scripts
demos/ota_nav2_sensor_fix/scripts/pack_artifact.py CLI to build tarballs + SOVD-shaped catalog/update entries
demos/ota_nav2_sensor_fix/scripts/e2e_webui_smoke.mjs Optional Playwright-based E2E smoke driver for the web UI flow
demos/ota_nav2_sensor_fix/scripts/conftest.py Pytest path setup for local script tests
demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh Optional host-side artifact build helper for maintainers
demos/ota_nav2_sensor_fix/scripts/.gitignore Ignores local Python/pytest artifacts for scripts
demos/ota_nav2_sensor_fix/run-demo.sh Main runner for building/starting the demo stack
demos/ota_nav2_sensor_fix/ros2_ws/.gitignore Ignores ROS 2 workspace build/install/log trees
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/urdf/warehouse_rbtheron.urdf.xacro RB-Theron URDF wrapper to avoid closed controller config and keep sensors
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/package.xml Package metadata for the demo launch/config glue
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/models/person_obstacle/model.sdf Simple box obstacle model used in the simulation narrative
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/models/person_obstacle/model.config Model config for person_obstacle
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/models/person_human/model.sdf Human-like obstacle model built from primitives (mesh-free)
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/models/person_human/model.config Model config for person_human
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/models/aws_small_warehouse/worlds/warehouse.sdf gz-port world file referencing AWS warehouse models
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/config/warehouse_map.yaml Map server YAML for the warehouse environment
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/config/ros_gz_bridge.yaml Bridge config, including /scan_sim mapping to avoid /scan stomping
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/config/navigate_to_pose_fast_abort.xml Nav2 BT tuned to abort faster for the demo narrative
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/config/nav2_params.yaml Nav2 params tailored for the RB-Theron + warehouse scenario
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/config/controllers.yaml ros2_control controller config for diff-drive + joint states
demos/ota_nav2_sensor_fix/ros2_packages/ota_nav2_sensor_fix_demo/CMakeLists.txt Installs launch/config/URDF/models/maps into the package share dir
demos/ota_nav2_sensor_fix/ros2_packages/health_check/src/health_check_node.cpp Operator-invoked Trigger services for differential diagnosis checks
demos/ota_nav2_sensor_fix/ros2_packages/health_check/package.xml health_check package manifest
demos/ota_nav2_sensor_fix/ros2_packages/health_check/CMakeLists.txt Builds/installs health_check_node
demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/src/fixed_lidar_node.cpp Post-fix clean /scan_sim/scan republisher
demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/package.xml fixed_lidar package manifest
demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/CMakeLists.txt Builds/installs fixed_lidar_node
demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/src/broken_lidar_node.cpp Regressed scan node that overlays a phantom sector after a driven distance
demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/package.xml broken_lidar package manifest
demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/CMakeLists.txt Builds/installs broken_lidar_node
demos/ota_nav2_sensor_fix/README.md Full demo narrative + curl equivalents + Foxglove walkthrough
demos/ota_nav2_sensor_fix/publish-fix.sh Registers the hotfix via POST /updates
demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py Tests for update server catalog + artifact endpoints
demos/ota_nav2_sensor_fix/ota_update_server/tests/__init__.py Marks test package
demos/ota_nav2_sensor_fix/ota_update_server/pyrightconfig.json Pyright config for the update server
demos/ota_nav2_sensor_fix/ota_update_server/pyproject.toml Python packaging for the update server
demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py FastAPI app serving /catalog and /artifacts/*
demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/__init__.py Exposes create_app
demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile Multi-stage build: builds demo artifacts, then serves via FastAPI
demos/ota_nav2_sensor_fix/ota_update_server/.gitignore Ignores venv/build artifacts
demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp GTest suite validating plugin behaviors and ProcessRunner spawn failure
demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_operation_dispatcher.cpp Tests operation-kind classification from metadata component arrays
demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_catalog_client.cpp Tests URL parsing logic for the CatalogClient
demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.hpp Declares process-management seam used by the plugin
demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp Implements pgrep/kill/spawn with cmdline-based matching and double-fork
demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp Gateway plugin entrypoints + UpdateProvider accessor
demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.hpp Defines operation-kind classification API
demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.cpp Implements operation-kind classification
demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.hpp Declares catalog/artifact HTTP client interface
demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.cpp Implements HTTP GET for /catalog and artifact downloads
demos/ota_nav2_sensor_fix/ota_update_plugin/package.xml Plugin package manifest
demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp Plugin class interface (GatewayPlugin + UpdateProvider)
demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt Builds static core lib + dlopen-loaded module + tests
demos/ota_nav2_sensor_fix/manifest.yaml Demo manifest defining components/apps/functions (hybrid discovery)
demos/ota_nav2_sensor_fix/gateway_config.yaml Gateway config enabling updates and loading ota_update_plugin
demos/ota_nav2_sensor_fix/entrypoint.sh Launches demo and auto-applies the bad update at boot
demos/ota_nav2_sensor_fix/Dockerfile.gateway Builds the full gateway + Nav2 + gz-sim + demo packages image
demos/ota_nav2_sensor_fix/docker-compose.yml Two-service stack: gateway + update server
demos/ota_nav2_sensor_fix/clear-fault.sh Clears the latched faults after applying the hotfix
demos/ota_nav2_sensor_fix/check-demo.sh Displays current update/fault/process state for the operator
demos/ota_nav2_sensor_fix/artifacts/.gitignore Ignores locally built artifacts
demos/ota_nav2_sensor_fix/apply-fix.sh Applies the published fix via prepare/execute
.github/workflows/ci.yml Adds CI jobs to build/run the OTA demo and execute smoke tests

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +21 to +25
parser.add_argument(
"--version",
default="0.0.0",
help="Semantic version of the artifact (pass '' for uninstall).",
)
ProcessRunner(ProcessRunner &&) = delete;
ProcessRunner & operator=(ProcessRunner &&) = delete;

/// Find PIDs of processes whose /proc/<pid>/comm matches the given basename.
bburda added 4 commits July 7, 2026 08:32
smoke_test_ota.sh asserted broken_lidar_node was the live scan_sensor_node with
a one-shot pgrep, but the entrypoint applies broken_lidar_3_0_0 asynchronously
after the catalog registers, so the check raced the async apply and flaked.
Gate on broken_lidar_3_0_0 reaching 'completed' before the "Initial process
state" section, and poll the process assertions instead of checking once
(matches the boot gate smoke_test_demo_narrative.sh already uses).
The gateway container multiplexes ~15 nodes into one stdout, so
'docker compose logs gateway --tail=N' on failure showed only discovery chatter
and never the nav2 goal handling. Add tests/dump_ota_diagnostics.sh and run it
from both OTA jobs on failure: it captures the full compose log plus ROS
introspection (lifecycle, /navigate_to_pose server, /scan, /amcl_pose, TF,
controllers, faults) and uploads it as an artifact.
… publish

send-goal.sh published /goal_pose once with 'ros2 topic pub --once' from a
transient docker exec node. On a cold CI runner that single volatile publish
raced DDS discovery and was dropped before bt_navigator matched the
subscription, so nav2 never drove, never aborted, and the reactive-fault
scenario raised nothing (ota-demo-narrative timed out waiting for
ACTION_NAVIGATE_TO_POSE_ABORTED). Send through the /navigate_to_pose action
instead: the client waits for the server and confirms the goal is accepted,
guaranteeing delivery, and returns on acceptance so a healthy-resume goal does
not block on the drive. Update the README/run-demo help to match.
Sending via the action exposed a second cold-start race: bt_navigator is
lifecycle-active and its action server is discoverable before it can actually
accept a goal (amcl has not published map->odom yet, so it has no pose to plan
from), so it rejects the goal. A goal fired right after boot was rejected once
and dropped. Retry send_goal_async until the goal is accepted (up to 90s), so
the reactive-fault step reliably starts the mission on a cold CI runner.
return tl::make_unexpected("artifact GET returned status " + std::to_string(res->status));
}

std::ofstream o(out_path, std::ios::binary);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

download_artifact writes the HTTP response body to out_path with no checksum or signature verification, and execute() then extracts and spawns it (ota_update_plugin.cpp:256-261). Catalog entries carry no digest field (pack_artifact.py emits only an advisory size), and the fetch is plaintext HTTP with TLS explicitly rejected (parse_url:144). Any MITM on the catalog/artifact channel, or a rogue ota_update_server, delivers an arbitrary tarball that runs as root. Add a mandatory sha256 field in the catalog entry, verify it against the downloaded bytes before extraction, and sign the catalog itself.

fs::remove_all(staging_extracted, ec);
fs::create_directories(staging_extracted, ec);

const std::string cmd = "tar -xzf " + staged_tarball + " -C " + staging_extracted;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

extract_and_swap builds tar -xzf <staged_tarball> -C <dir> and runs it via std::system, and staged_tarball embeds the catalog-controlled id (staged_path = staging_dir_ + "/" + id + ".tar.gz", prepare() line 201). The id is taken verbatim from the catalog fetched over plain HTTP with no charset check in register_update, so an entry whose id is x; rm -rf / ; yields shell execution as root at execute() time. Drop the shell: fork+execvp (or an absolute tar path) passing an argv vector, and reject any id not matching ^[A-Za-z0-9._-]+$ in register_update.

--skip-keys ur_description \
--skip-keys ur_simulation_gz && \
colcon build \
--cmake-args -DCMAKE_BUILD_TYPE=Release -DBUILD_TESTING=OFF && \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gateway image builds the plugin with -DBUILD_TESTING=OFF, and no CI job runs colcon test or pytest. All three test suites this PR adds (ota_update_plugin gtest in ota_update_plugin/test/, ota_update_server/tests/test_main.py, scripts/test_pack_artifact.py) are therefore never executed anywhere in the pipeline; CI only builds the Docker images and runs the shell smoke tests. A regression in the dispatcher/catalog client/packer parsing passes CI green. Add a job that runs the gtest suite (BUILD_TESTING=ON + colcon test) and the two pytest suites.

RMW_IMPLEMENTATION=rmw_cyclonedds_cpp

EXPOSE 8080 8765
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Neither this image nor ota_update_server/Dockerfile sets a USER, so the gateway runs as uid 0. The OTA execute() flow (kill_by_executable, tar extraction of the fetched tarball, exec of the artifact binary) therefore runs with full in-container root and no capability restrictions. A tampered catalog or artifact escalates directly to root inside the container. Add a non-root USER for the runtime stage, and set cap_drop: [ALL] plus security_opt: [no-new-privileges:true] on the gateway service in docker-compose.yml.


# CORS so an external Foxglove panel or browser can hit the API.
cors:
allowed_origins: ["*"]

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The gateway binds 0.0.0.0:8080 with allowed_origins [*] and no authentication while updates.enabled is true, and 8080 is published to the host in docker-compose.yml. Any host or browser on the network can PUT /updates/{id}/prepare and /execute, driving the kill/extract/exec chain. The real OTA product must require auth on the update endpoints; even in the demo, note this or bind the API to the internal network only.

if (!target_package.empty()) {
// Best-effort kill: legacy nodes may use the package name as their executable basename.
// Failures are tolerated since the process may already be gone.
auto kr = process_runner_->kill_by_executable(target_package);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Install spawns the node via install_dir/target_package/lib/target_package/executable, so its argv[0] basename is x_medkit_executable. Uninstall calls kill_by_executable(target_package), which pgrep matches on argv[0] basename and never matches the running node (basename is the executable, not the package). The files are then removed out from under a still-running process, leaving an orphaned live node. Kill by the executable basename (record it at install), not target_package.

// Entities defined in the base manifest stay - fragments only
// ADD, they can't remove base-manifest declarations - those
// entries just go offline.
if (auto fr = remove_install_fragment(id); !fr) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Install writes the fragment as ${update_id}.yaml where update_id is the install entry id (pack_artifact slug, e.g. foo_1_0_0), but Uninstall calls remove_install_fragment(id) with the uninstall entry id (${package}_remove, e.g. foo_remove). remove_install_fragment builds a different filename, hits no_such_file_or_directory (tolerated), and the install fragment is left in fragments_dir. The uninstalled app therefore stays in the entity tree forever. Key the fragment filename on the target package/component (shared across install and uninstall), not the per-entry update id.

return tl::make_unexpected("create fragments_dir failed: " + ec.message());
}

const std::string final_path = fragments_dir_ + "/" + update_id + ".yaml";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

update_id (the catalog id) is concatenated into final_path/tmp_path = fragments_dir_ + "/" + update_id + ".yaml" with no sanitization, so an id containing '../' writes a gateway manifest fragment to an arbitrary path as root. Separately, render_install_fragment interpolates app_id (added_components[0]) and node_name (x_medkit_executable) into the YAML body unquoted, so a value carrying a newline injects arbitrary apps/keys into the merged manifest that drives discovery. Validate update_id against [A-Za-z0-9._-]+ and emit the fragment with a real YAML serializer.

// gzipped archive) but the kill step runs first - that is what we are
// checking here.
auto rc = plugin.execute("u_replaces", reporter);
(void)rc;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Both execute() tests feed a non-gzip staged tarball so extract_and_swap fails, then drop the result with (void)rc and assert only last_kill_target. Nothing asserts execute() success, the extract->swap->spawn path, or recovery after a mid-update failure, so these pass green even if the swap silently corrupts the install or spawn is removed. execute() kills the old process (ota_update_plugin.cpp:251) before extract/spawn and has no rollback on failure, so the exact scenario these tests trigger leaves the robot with no lidar node running - and no test catches it. Add a test that stages a real tarball and asserts rc succeeds and the binary is swapped, plus one asserting the failed-swap-after-kill behavior.

Comment thread demos/ota_nav2_sensor_fix/send-goal.sh Outdated
# ros2 is not on the container's default PATH and `docker exec` does not run the
# image entrypoint that sources it, so source the ROS overlay inside the exec.
docker exec ota_demo_gateway bash -c \
"source /opt/ros/jazzy/setup.bash && source /ws/install/setup.bash && ros2 topic pub --once /goal_pose geometry_msgs/PoseStamped '${GOAL}'"

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

X and Y (positional args, send-goal.sh:5) are interpolated into GOAL and then into a docker exec ota_demo_gateway bash -c "... '${GOAL}'" string where GOAL sits inside single quotes. A single quote in the argument breaks out of that quoting and runs arbitrary commands as root in the container, e.g. ./send-goal.sh "0'; touch /pwned; echo '0" executes touch /pwned. Validate that X and Y are numeric before use, or pass the goal to ros2 topic pub via a file/stdin instead of string-interpolating it into bash -c.

bburda added 2 commits July 7, 2026 11:59
- pack_artifact.py: --version defaults to '' so an omitted version fails loud
  for update/install instead of silently packing a 0.0.0 artifact.
- process_runner: correct the pgrep doc to cmdline argv0 (not comm, which the
  kernel truncates to 15 chars).
- docker-compose: block privilege escalation on the gateway (no-new-privileges);
  cap_drop / non-root USER are documented as production hardening, not applied
  here (they risk breaking gz-sim / DDS in this all-in-one demo container).
…e-then-swap)

- Reject catalog ids/components/executables/target packages that are not
  [A-Za-z0-9._-] before they reach a path or exec (blocks '../' traversal and
  argument injection).
- Extract the artifact with fork+execvp instead of std::system, so a
  catalog-controlled name can never be interpreted by a shell.
- Stage-then-swap: extract and verify the new tree before killing the running
  node, so a bad tarball no longer leaves the sensor with no lidar.
- Track processes the plugin spawned (keyed by component) and also kill the
  recorded pid on re-apply, guarded by a /proc/<pid>/cmdline argv0 check against
  pid reuse - fixes the duplicate scan_sensor_node on a repeated apply.
- Uninstall kills the recorded executable (not the package name, which never
  matched argv0) and removes the install fragment keyed on the component, so an
  uninstalled app no longer lingers in the entity tree.
- Emit install-fragment scalars as quoted YAML.
- Rewrite the process-runner test stub into a stateful fake covering the swap,
  duplicate-spawn, uninstall, traversal-rejection and quoting paths.
- README: document the demo's trusted-local security model and what a production
  deployment would add; correct the swap note.
…n CI

The plugin image built with -DBUILD_TESTING=OFF and no job ran colcon test or
pytest, so the plugin gtest, the ota_update_server tests and the pack_artifact
tests never executed anywhere. Add a Dockerfile 'ota-plugin-test' stage that
rebuilds the plugin with BUILD_TESTING=ON and runs its gtest, and a
'plugin-tests' CI job that builds that stage and runs both pytest suites.
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.

3 participants