From 4cc3700b52ffa76ba4c77c2ed70f5a064befe3f9 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:47:09 +0200 Subject: [PATCH 01/36] feat(demos/ota): scaffold pack_artifact CLI --- demos/ota_nav2_sensor_fix/scripts/.gitignore | 4 ++++ demos/ota_nav2_sensor_fix/scripts/conftest.py | 7 +++++++ .../scripts/pack_artifact.py | 21 +++++++++++++++++++ .../scripts/test_pack_artifact.py | 14 +++++++++++++ 4 files changed, 46 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/scripts/.gitignore create mode 100644 demos/ota_nav2_sensor_fix/scripts/conftest.py create mode 100644 demos/ota_nav2_sensor_fix/scripts/pack_artifact.py create mode 100644 demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py diff --git a/demos/ota_nav2_sensor_fix/scripts/.gitignore b/demos/ota_nav2_sensor_fix/scripts/.gitignore new file mode 100644 index 0000000..17cd2fc --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/.gitignore @@ -0,0 +1,4 @@ +.venv/ +__pycache__/ +*.pyc +.pytest_cache/ diff --git a/demos/ota_nav2_sensor_fix/scripts/conftest.py b/demos/ota_nav2_sensor_fix/scripts/conftest.py new file mode 100644 index 0000000..85d7c2d --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/conftest.py @@ -0,0 +1,7 @@ +"""Pytest fixtures for pack_artifact tests.""" +from __future__ import annotations + +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).parent)) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py new file mode 100644 index 0000000..700153e --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +"""Pack a ROS 2 package into an OTA artifact + SOVD-shaped catalog entry.""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import tarfile +from pathlib import Path +from typing import Literal + +Kind = Literal["update", "install", "uninstall"] + + +def main(argv: list[str] | None = None) -> int: + raise NotImplementedError + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py new file mode 100644 index 0000000..0ea65a2 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -0,0 +1,14 @@ +"""Tests for pack_artifact.py.""" +from __future__ import annotations + +import json +import tarfile +from pathlib import Path + +import pytest + +import pack_artifact + + +def test_imports(): + assert hasattr(pack_artifact, "main") From 0a3ddafb8ad7324a482e808573f15ddad2bc9cca Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:47:41 +0200 Subject: [PATCH 02/36] feat(demos/ota): pack_artifact argparse + dispatcher signature --- .../scripts/pack_artifact.py | 64 ++++++++++++++++++- .../scripts/test_pack_artifact.py | 36 +++++++++++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index 700153e..ae5e591 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -13,9 +13,71 @@ Kind = Literal["update", "install", "uninstall"] -def main(argv: list[str] | None = None) -> int: +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser( + description="Pack a ROS 2 package into an OTA artifact + SOVD catalog entry.", + ) + parser.add_argument("--package", required=True, help="ROS 2 package name to pack.") + parser.add_argument( + "--version", + default="", + help="Semantic version of the artifact (omit for uninstall).", + ) + parser.add_argument( + "--kind", + required=True, + choices=["update", "install", "uninstall"], + help="Catalog entry kind.", + ) + parser.add_argument( + "--target-component", + required=True, + help="SOVD component the entry targets.", + ) + parser.add_argument( + "--executable", + default="", + help="Executable name inside install//lib (required for install).", + ) + parser.add_argument("--notes", default="", help="Free-text notes for the catalog entry.") + parser.add_argument( + "--duration", + type=int, + default=10, + help="Estimated install duration in seconds.", + ) + parser.add_argument( + "--out-dir", + default="artifacts", + help="Output directory for tarballs.", + ) + parser.add_argument( + "--catalog", + default="artifacts/catalog.json", + help="Path to the SOVD catalog JSON file.", + ) + parser.add_argument( + "--skip-build", + action="store_true", + help="Skip running colcon build; reuse existing install/ tree.", + ) + parser.add_argument( + "--workspace", + default=".", + help="Path to the colcon workspace root.", + ) + return parser + + +def run(**kwargs) -> int: raise NotImplementedError +def main(argv: list[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + return run(**vars(args)) + + if __name__ == "__main__": sys.exit(main()) diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index 0ea65a2..6ba155b 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -12,3 +12,39 @@ def test_imports(): assert hasattr(pack_artifact, "main") + + +def test_main_requires_package(): + with pytest.raises(SystemExit): + pack_artifact.main([]) + + +def test_main_parses_basic_args(monkeypatch, tmp_path): + captured = {} + + def fake_run(**kwargs): + captured.update(kwargs) + return 0 + + monkeypatch.setattr(pack_artifact, "run", fake_run) + rc = pack_artifact.main( + [ + "--package", "fixed_lidar", + "--version", "2.1.0", + "--kind", "update", + "--target-component", "scan_sensor_node", + "--executable", "fixed_lidar_node", + "--notes", "noise filter fix", + "--out-dir", str(tmp_path / "artifacts"), + "--catalog", str(tmp_path / "artifacts" / "catalog.json"), + "--skip-build", + ] + ) + assert rc == 0 + assert captured["package"] == "fixed_lidar" + assert captured["version"] == "2.1.0" + assert captured["kind"] == "update" + assert captured["target_component"] == "scan_sensor_node" + assert captured["executable"] == "fixed_lidar_node" + assert captured["notes"] == "noise filter fix" + assert captured["skip_build"] is True From 3a5b41e809a46f802d2312092029e304b77d5b8b Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:48:06 +0200 Subject: [PATCH 03/36] feat(demos/ota): build SOVD-shaped catalog entry --- .../scripts/pack_artifact.py | 46 ++++++++++++++ .../scripts/test_pack_artifact.py | 61 +++++++++++++++++++ 2 files changed, 107 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index ae5e591..c8dfc0e 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -69,6 +69,52 @@ def build_parser() -> argparse.ArgumentParser: return parser +def slug(package: str, version: str) -> str: + return f"{package}_{version.replace('.', '_')}" if version else package + + +def build_entry( + *, + package: str, + version: str, + kind: Kind, + target_component: str, + executable: str, + notes: str, + duration: int, + size_bytes: int, +) -> dict: + entry: dict = { + "id": slug(package, version) if kind != "uninstall" else f"{package}_remove", + "name": f"{package} {version}".strip(), + "automated": False, + "origins": ["remote"], + "notes": notes, + "duration": duration, + } + if version: + entry["version"] = version + if size_bytes > 0: + entry["size"] = max(1, size_bytes // 1024) + + if kind == "update": + entry["updated_components"] = [target_component] + elif kind == "install": + entry["added_components"] = [target_component] + else: # uninstall + entry["removed_components"] = [target_component] + + if kind != "uninstall": + entry["x_medkit_artifact_url"] = f"/artifacts/{package}-{version}.tar.gz" + entry["x_medkit_target_package"] = package + if executable: + entry["x_medkit_executable"] = executable + else: + entry["x_medkit_target_package"] = package + + return entry + + def run(**kwargs) -> int: raise NotImplementedError diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index 6ba155b..20208a8 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -48,3 +48,64 @@ def fake_run(**kwargs): assert captured["executable"] == "fixed_lidar_node" assert captured["notes"] == "noise filter fix" assert captured["skip_build"] is True + + +def test_build_entry_update_kind(): + entry = pack_artifact.build_entry( + package="fixed_lidar", + version="2.1.0", + kind="update", + target_component="scan_sensor_node", + executable="fixed_lidar_node", + notes="fix noise", + duration=10, + size_bytes=2048, + ) + assert entry["id"] == "fixed_lidar_2_1_0" + assert entry["name"] == "fixed_lidar 2.1.0" + assert entry["version"] == "2.1.0" + assert entry["automated"] is False + assert entry["origins"] == ["remote"] + assert entry["notes"] == "fix noise" + assert entry["size"] == 2 # KB rounded + assert entry["duration"] == 10 + assert entry["updated_components"] == ["scan_sensor_node"] + assert "added_components" not in entry + assert "removed_components" not in entry + assert entry["x_medkit_target_package"] == "fixed_lidar" + assert entry["x_medkit_executable"] == "fixed_lidar_node" + assert entry["x_medkit_artifact_url"] == "/artifacts/fixed_lidar-2.1.0.tar.gz" + + +def test_build_entry_install_kind(): + entry = pack_artifact.build_entry( + package="obstacle_classifier_v2", + version="1.0.0", + kind="install", + target_component="obstacle_classifier", + executable="obstacle_classifier_node", + notes="extra safety", + duration=15, + size_bytes=4096, + ) + assert entry["added_components"] == ["obstacle_classifier"] + assert "updated_components" not in entry + assert "removed_components" not in entry + + +def test_build_entry_uninstall_kind(): + entry = pack_artifact.build_entry( + package="broken_lidar_legacy", + version="", + kind="uninstall", + target_component="broken_lidar_legacy", + executable="", + notes="cleanup", + duration=5, + size_bytes=0, + ) + assert entry["removed_components"] == ["broken_lidar_legacy"] + assert "added_components" not in entry + assert "updated_components" not in entry + assert "x_medkit_artifact_url" not in entry + assert "x_medkit_executable" not in entry From 3d22b162bee28e1ae7c776df9bfb33af481f3bea Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:48:28 +0200 Subject: [PATCH 04/36] feat(demos/ota): merge_catalog with id-based replace --- .../scripts/pack_artifact.py | 12 +++++++++ .../scripts/test_pack_artifact.py | 26 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index c8dfc0e..d26bf74 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -115,6 +115,18 @@ def build_entry( return entry +def merge_catalog(catalog_path: Path, entry: dict) -> None: + catalog_path = Path(catalog_path) + catalog_path.parent.mkdir(parents=True, exist_ok=True) + if catalog_path.exists(): + data = json.loads(catalog_path.read_text()) + else: + data = [] + data = [e for e in data if e.get("id") != entry["id"]] + data.append(entry) + catalog_path.write_text(json.dumps(data, indent=2) + "\n") + + def run(**kwargs) -> int: raise NotImplementedError diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index 20208a8..f2d9e5d 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -109,3 +109,29 @@ def test_build_entry_uninstall_kind(): assert "updated_components" not in entry assert "x_medkit_artifact_url" not in entry assert "x_medkit_executable" not in entry + + +def test_merge_catalog_creates_file(tmp_path): + catalog = tmp_path / "catalog.json" + entry = {"id": "a", "name": "a"} + pack_artifact.merge_catalog(catalog, entry) + data = json.loads(catalog.read_text()) + assert data == [entry] + + +def test_merge_catalog_appends(tmp_path): + catalog = tmp_path / "catalog.json" + catalog.write_text(json.dumps([{"id": "a", "name": "a"}])) + entry = {"id": "b", "name": "b"} + pack_artifact.merge_catalog(catalog, entry) + data = json.loads(catalog.read_text()) + assert [e["id"] for e in data] == ["a", "b"] + + +def test_merge_catalog_replaces_same_id(tmp_path): + catalog = tmp_path / "catalog.json" + catalog.write_text(json.dumps([{"id": "a", "name": "old"}])) + entry = {"id": "a", "name": "new"} + pack_artifact.merge_catalog(catalog, entry) + data = json.loads(catalog.read_text()) + assert data == [entry] From 876b960c85a538299c895532767bd47a8697b5b0 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:49:03 +0200 Subject: [PATCH 05/36] feat(demos/ota): tarball creation from install dir --- .../scripts/pack_artifact.py | 18 ++++++++++++++++++ .../scripts/test_pack_artifact.py | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index d26bf74..719b589 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -127,6 +127,24 @@ def merge_catalog(catalog_path: Path, entry: dict) -> None: catalog_path.write_text(json.dumps(data, indent=2) + "\n") +def create_tarball( + *, + package: str, + version: str, + install_dir: Path, + out_dir: Path, +) -> Path: + install_dir = Path(install_dir) + if not install_dir.exists(): + raise FileNotFoundError(f"install dir does not exist: {install_dir}") + out_dir = Path(out_dir) + out_dir.mkdir(parents=True, exist_ok=True) + out_path = out_dir / f"{package}-{version}.tar.gz" + with tarfile.open(out_path, "w:gz") as tf: + tf.add(install_dir, arcname=package) + return out_path + + def run(**kwargs) -> int: raise NotImplementedError diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index f2d9e5d..e0aa942 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -135,3 +135,21 @@ def test_merge_catalog_replaces_same_id(tmp_path): pack_artifact.merge_catalog(catalog, entry) data = json.loads(catalog.read_text()) assert data == [entry] + + +def test_create_tarball(tmp_path): + install = tmp_path / "install" / "fixed_lidar" + (install / "lib").mkdir(parents=True) + (install / "lib" / "fixed_lidar_node").write_text("binary") + out_dir = tmp_path / "artifacts" + out_path = pack_artifact.create_tarball( + package="fixed_lidar", + version="2.1.0", + install_dir=install, + out_dir=out_dir, + ) + assert out_path == out_dir / "fixed_lidar-2.1.0.tar.gz" + assert out_path.exists() + with tarfile.open(out_path) as tf: + names = tf.getnames() + assert "fixed_lidar/lib/fixed_lidar_node" in names From f86c7658cd7861f091b40c05b1685486330631dc Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:49:37 +0200 Subject: [PATCH 06/36] feat(demos/ota): pack_artifact end-to-end run() with kind dispatch --- .../scripts/pack_artifact.py | 60 +++++++++++++++- .../scripts/test_pack_artifact.py | 72 +++++++++++++++++++ 2 files changed, 130 insertions(+), 2 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index 719b589..c8d663c 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -145,8 +145,64 @@ def create_tarball( return out_path -def run(**kwargs) -> int: - raise NotImplementedError +def colcon_build(workspace: Path, package: str) -> None: + cmd = ["colcon", "build", "--packages-select", package, "--symlink-install"] + completed = subprocess.run(cmd, cwd=workspace, check=False) + if completed.returncode != 0: + raise SystemExit(f"colcon build failed for {package}") + + +def run( + *, + package: str, + version: str, + kind: Kind, + target_component: str, + executable: str, + notes: str, + duration: int, + out_dir: str, + catalog: str, + skip_build: bool, + workspace: str, +) -> int: + if kind == "install" and not executable: + sys.stderr.write("--executable is required for install\n") + raise SystemExit(2) + + out_dir_p = Path(out_dir) + catalog_p = Path(catalog) + workspace_p = Path(workspace) + + size_bytes = 0 + if kind != "uninstall": + if not skip_build: + colcon_build(workspace_p, package) + install_dir = workspace_p / "install" / package + if not install_dir.exists(): + sys.stderr.write(f"install dir missing: {install_dir}\n") + raise SystemExit(3) + tarball = create_tarball( + package=package, + version=version, + install_dir=install_dir, + out_dir=out_dir_p, + ) + size_bytes = tarball.stat().st_size + + entry = build_entry( + package=package, + version=version, + kind=kind, + target_component=target_component, + executable=executable, + notes=notes, + duration=duration, + size_bytes=size_bytes, + ) + merge_catalog(catalog_p, entry) + print(f"packed {entry['id']}") + return 0 def main(argv: list[str] | None = None) -> int: diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index e0aa942..871f5cd 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -153,3 +153,75 @@ def test_create_tarball(tmp_path): with tarfile.open(out_path) as tf: names = tf.getnames() assert "fixed_lidar/lib/fixed_lidar_node" in names + + +def test_run_update_kind_e2e(tmp_path, monkeypatch): + workspace = tmp_path / "ws" + install = workspace / "install" / "fixed_lidar" / "lib" + install.mkdir(parents=True) + (install / "fixed_lidar_node").write_text("bin") + out_dir = tmp_path / "artifacts" + catalog = out_dir / "catalog.json" + + rc = pack_artifact.run( + package="fixed_lidar", + version="2.1.0", + kind="update", + target_component="scan_sensor_node", + executable="fixed_lidar_node", + notes="fix", + duration=10, + out_dir=str(out_dir), + catalog=str(catalog), + skip_build=True, + workspace=str(workspace), + ) + + assert rc == 0 + assert (out_dir / "fixed_lidar-2.1.0.tar.gz").exists() + data = json.loads(catalog.read_text()) + assert data[0]["id"] == "fixed_lidar_2_1_0" + assert data[0]["updated_components"] == ["scan_sensor_node"] + + +def test_run_uninstall_skips_tarball(tmp_path): + workspace = tmp_path / "ws" + workspace.mkdir() + out_dir = tmp_path / "artifacts" + catalog = out_dir / "catalog.json" + + rc = pack_artifact.run( + package="broken_lidar_legacy", + version="", + kind="uninstall", + target_component="broken_lidar_legacy", + executable="", + notes="cleanup", + duration=5, + out_dir=str(out_dir), + catalog=str(catalog), + skip_build=True, + workspace=str(workspace), + ) + + assert rc == 0 + assert not list(out_dir.glob("*.tar.gz")) + data = json.loads(catalog.read_text()) + assert data[0]["removed_components"] == ["broken_lidar_legacy"] + + +def test_run_install_requires_executable(tmp_path): + with pytest.raises(SystemExit): + pack_artifact.run( + package="obstacle_classifier_v2", + version="1.0.0", + kind="install", + target_component="obstacle_classifier", + executable="", + notes="", + duration=10, + out_dir=str(tmp_path / "out"), + catalog=str(tmp_path / "out" / "catalog.json"), + skip_build=True, + workspace=str(tmp_path / "ws"), + ) From 161b707935ba326a415db0a869aa7a6cbf411d7e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:53:58 +0200 Subject: [PATCH 07/36] fix(demos/ota): version default to 0.0.0 + cleanup unused symbols + pyright config --- demos/ota_nav2_sensor_fix/scripts/pack_artifact.py | 4 ++-- demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json | 5 +++++ demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py | 3 +-- 3 files changed, 8 insertions(+), 4 deletions(-) create mode 100644 demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index c8d663c..4413524 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -20,8 +20,8 @@ def build_parser() -> argparse.ArgumentParser: parser.add_argument("--package", required=True, help="ROS 2 package name to pack.") parser.add_argument( "--version", - default="", - help="Semantic version of the artifact (omit for uninstall).", + default="0.0.0", + help="Semantic version of the artifact (pass '' for uninstall).", ) parser.add_argument( "--kind", diff --git a/demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json b/demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json new file mode 100644 index 0000000..5b3e8d0 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/pyrightconfig.json @@ -0,0 +1,5 @@ +{ + "extraPaths": ["."], + "venvPath": ".", + "venv": ".venv" +} diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index 871f5cd..6b3f49f 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -3,7 +3,6 @@ import json import tarfile -from pathlib import Path import pytest @@ -155,7 +154,7 @@ def test_create_tarball(tmp_path): assert "fixed_lidar/lib/fixed_lidar_node" in names -def test_run_update_kind_e2e(tmp_path, monkeypatch): +def test_run_update_kind_e2e(tmp_path): workspace = tmp_path / "ws" install = workspace / "install" / "fixed_lidar" / "lib" install.mkdir(parents=True) From 398842bda46f585c0c6c43104d7a4aa6df1545d1 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 17:57:24 +0200 Subject: [PATCH 08/36] test(demos/ota): cover colcon_build, install e2e, version-required guard --- .../scripts/pack_artifact.py | 3 + .../scripts/test_pack_artifact.py | 84 ++++++++++++++++++- 2 files changed, 83 insertions(+), 4 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index 4413524..06f3df1 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -169,6 +169,9 @@ def run( if kind == "install" and not executable: sys.stderr.write("--executable is required for install\n") raise SystemExit(2) + if kind != "uninstall" and not version: + sys.stderr.write(f"--version is required for kind={kind}\n") + raise SystemExit(2) out_dir_p = Path(out_dir) catalog_p = Path(catalog) diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index 6b3f49f..bced302 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -9,10 +9,6 @@ import pack_artifact -def test_imports(): - assert hasattr(pack_artifact, "main") - - def test_main_requires_package(): with pytest.raises(SystemExit): pack_artifact.main([]) @@ -224,3 +220,83 @@ def test_run_install_requires_executable(tmp_path): skip_build=True, workspace=str(tmp_path / "ws"), ) + + +def test_run_update_requires_version(tmp_path): + with pytest.raises(SystemExit): + pack_artifact.run( + package="fixed_lidar", + version="", + kind="update", + target_component="scan_sensor_node", + executable="fixed_lidar_node", + notes="", + duration=10, + out_dir=str(tmp_path / "out"), + catalog=str(tmp_path / "out" / "catalog.json"), + skip_build=True, + workspace=str(tmp_path / "ws"), + ) + + +def test_run_install_kind_e2e(tmp_path): + workspace = tmp_path / "ws" + install = workspace / "install" / "obstacle_classifier_v2" / "lib" + install.mkdir(parents=True) + (install / "obstacle_classifier_node").write_text("bin") + out_dir = tmp_path / "artifacts" + catalog = out_dir / "catalog.json" + + rc = pack_artifact.run( + package="obstacle_classifier_v2", + version="1.0.0", + kind="install", + target_component="obstacle_classifier", + executable="obstacle_classifier_node", + notes="extra safety", + duration=15, + out_dir=str(out_dir), + catalog=str(catalog), + skip_build=True, + workspace=str(workspace), + ) + + assert rc == 0 + assert (out_dir / "obstacle_classifier_v2-1.0.0.tar.gz").exists() + data = json.loads(catalog.read_text()) + assert data[0]["id"] == "obstacle_classifier_v2_1_0_0" + assert data[0]["added_components"] == ["obstacle_classifier"] + assert data[0]["x_medkit_executable"] == "obstacle_classifier_node" + + +def test_colcon_build_invokes_subprocess(tmp_path, monkeypatch): + captured = {} + + class FakeCompleted: + returncode = 0 + + def fake_run(cmd, cwd, check): + captured["cmd"] = cmd + captured["cwd"] = cwd + captured["check"] = check + return FakeCompleted() + + monkeypatch.setattr(pack_artifact.subprocess, "run", fake_run) + pack_artifact.colcon_build(tmp_path, "broken_lidar") + + assert captured["cmd"] == [ + "colcon", "build", "--packages-select", "broken_lidar", "--symlink-install" + ] + assert captured["cwd"] == tmp_path + assert captured["check"] is False + + +def test_colcon_build_raises_on_nonzero(tmp_path, monkeypatch): + class FakeCompleted: + returncode = 1 + + monkeypatch.setattr( + pack_artifact.subprocess, "run", lambda *_args, **_kwargs: FakeCompleted() + ) + with pytest.raises(SystemExit): + pack_artifact.colcon_build(tmp_path, "broken_lidar") From 216acc75dfac67bc33399d6c39ae5e93688993d3 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:00:23 +0200 Subject: [PATCH 09/36] feat(demos/ota): ota_update_server scaffold --- .../ota_update_server/.gitignore | 3 ++ .../ota_update_server/__init__.py | 3 ++ .../ota_update_server/main.py | 43 +++++++++++++++++++ .../ota_update_server/pyproject.toml | 22 ++++++++++ .../ota_update_server/tests/__init__.py | 0 .../ota_update_server/tests/test_main.py | 20 +++++++++ 6 files changed, 91 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/.gitignore create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/__init__.py create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/pyproject.toml create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/tests/__init__.py create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/.gitignore b/demos/ota_nav2_sensor_fix/ota_update_server/.gitignore new file mode 100644 index 0000000..d0ad410 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/.gitignore @@ -0,0 +1,3 @@ +.venv/ +*.egg-info/ +__pycache__/ diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/__init__.py b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/__init__.py new file mode 100644 index 0000000..813f14c --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/__init__.py @@ -0,0 +1,3 @@ +from .main import create_app + +__all__ = ["create_app"] diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py new file mode 100644 index 0000000..23d1d44 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py @@ -0,0 +1,43 @@ +"""Minimal FastAPI artifact host for the OTA demo.""" +from __future__ import annotations + +import json +import os +from pathlib import Path + +from fastapi import FastAPI, HTTPException +from fastapi.responses import FileResponse + + +def create_app(artifacts_dir: Path) -> FastAPI: + app = FastAPI(title="OTA Update Server") + artifacts_dir = Path(artifacts_dir) + + @app.get("/catalog") + def catalog() -> list[dict]: + catalog_file = artifacts_dir / "catalog.json" + if not catalog_file.exists(): + return [] + return json.loads(catalog_file.read_text()) + + @app.get("/artifacts/{filename}") + def artifact(filename: str) -> FileResponse: + if "/" in filename or ".." in filename: + raise HTTPException(status_code=400, detail="invalid filename") + path = artifacts_dir / filename + if not path.exists(): + raise HTTPException(status_code=404, detail="not found") + return FileResponse(path, media_type="application/gzip", filename=filename) + + return app + + +def run() -> None: + import uvicorn + artifacts_dir = Path(os.environ.get("OTA_ARTIFACTS_DIR", "/artifacts")) + host = os.environ.get("OTA_HOST", "0.0.0.0") + port = int(os.environ.get("OTA_PORT", "9000")) + uvicorn.run(create_app(artifacts_dir), host=host, port=port) + + +__all__ = ["create_app", "run"] diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/pyproject.toml b/demos/ota_nav2_sensor_fix/ota_update_server/pyproject.toml new file mode 100644 index 0000000..3479fa6 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/pyproject.toml @@ -0,0 +1,22 @@ +[project] +name = "ota_update_server" +version = "0.1.0" +description = "Minimal FastAPI artifact host for OTA demo" +requires-python = ">=3.11" +dependencies = [ + "fastapi>=0.110", + "uvicorn[standard]>=0.29", +] + +[project.optional-dependencies] +dev = [ + "pytest>=8", + "httpx>=0.27", +] + +[project.scripts] +ota-update-server = "ota_update_server.main:run" + +[build-system] +requires = ["hatchling"] +build-backend = "hatchling.build" diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/tests/__init__.py b/demos/ota_nav2_sensor_fix/ota_update_server/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py new file mode 100644 index 0000000..367f485 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py @@ -0,0 +1,20 @@ +"""Tests for the FastAPI update server.""" +from __future__ import annotations + +import json +from pathlib import Path + +import pytest +from fastapi.testclient import TestClient + +from ota_update_server import create_app + + +@pytest.fixture +def artifacts_dir(tmp_path) -> Path: + return tmp_path + + +@pytest.fixture +def client(artifacts_dir): + return TestClient(create_app(artifacts_dir)) From e42af7e6d101b6316a30d6bbbf40de5e2f1ebab6 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:00:44 +0200 Subject: [PATCH 10/36] test(ota_server): /catalog endpoint coverage --- .../ota_update_server/tests/test_main.py | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py index 367f485..c02066f 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py +++ b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py @@ -18,3 +18,20 @@ def artifacts_dir(tmp_path) -> Path: @pytest.fixture def client(artifacts_dir): return TestClient(create_app(artifacts_dir)) + + +def test_catalog_empty_when_missing(client): + resp = client.get("/catalog") + assert resp.status_code == 200 + assert resp.json() == [] + + +def test_catalog_returns_file_contents(client, artifacts_dir): + payload = [ + {"id": "fixed_lidar_2_1_0", "updated_components": ["scan_sensor_node"]}, + {"id": "obstacle_classifier_v2_install", "added_components": ["obstacle_classifier"]}, + ] + (artifacts_dir / "catalog.json").write_text(json.dumps(payload)) + resp = client.get("/catalog") + assert resp.status_code == 200 + assert resp.json() == payload From ab10c2987ff9ef337243d8d1530bcdd71b67d3aa Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:01:04 +0200 Subject: [PATCH 11/36] test(ota_server): /artifacts endpoint + path traversal guard --- .../ota_update_server/tests/test_main.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py index c02066f..5414d0e 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py +++ b/demos/ota_nav2_sensor_fix/ota_update_server/tests/test_main.py @@ -35,3 +35,21 @@ def test_catalog_returns_file_contents(client, artifacts_dir): resp = client.get("/catalog") assert resp.status_code == 200 assert resp.json() == payload + + +def test_artifact_returns_file(client, artifacts_dir): + (artifacts_dir / "fixed_lidar-2.1.0.tar.gz").write_bytes(b"BIN") + resp = client.get("/artifacts/fixed_lidar-2.1.0.tar.gz") + assert resp.status_code == 200 + assert resp.content == b"BIN" + + +def test_artifact_404_when_missing(client): + resp = client.get("/artifacts/missing.tar.gz") + assert resp.status_code == 404 + + +def test_artifact_rejects_path_traversal(client, artifacts_dir): + (artifacts_dir.parent / "secret.txt").write_text("hush") + resp = client.get("/artifacts/..%2Fsecret.txt") + assert resp.status_code in (400, 404) From 9b71e5a86ff0cbdb2148791a094a7e3365bc57c8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:03:29 +0200 Subject: [PATCH 12/36] feat(demos/ota): ota_update_server Dockerfile --- .../ota_update_server/Dockerfile | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile b/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile new file mode 100644 index 0000000..e5a59af --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile @@ -0,0 +1,13 @@ +FROM python:3.11-slim + +WORKDIR /app +COPY pyproject.toml ./ +COPY ota_update_server ./ota_update_server +RUN pip install --no-cache-dir . + +ENV OTA_ARTIFACTS_DIR=/artifacts +ENV OTA_HOST=0.0.0.0 +ENV OTA_PORT=9000 +EXPOSE 9000 + +CMD ["ota-update-server"] From 1f838deba4ad5bc76308e24e80fe8495414c6814 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:04:26 +0200 Subject: [PATCH 13/36] chore(ota_server): pyright config to silence venv import warnings --- .../ota_update_server/pyrightconfig.json | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ota_update_server/pyrightconfig.json diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/pyrightconfig.json b/demos/ota_nav2_sensor_fix/ota_update_server/pyrightconfig.json new file mode 100644 index 0000000..49f6c16 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_server/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "include": ["ota_update_server", "tests"], + "venvPath": ".", + "venv": ".venv", + "reportUnusedFunction": "none" +} From 653902be34b8d443881c50d61a9d0d5c127b0991 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:07:07 +0200 Subject: [PATCH 14/36] fix(ota_server): mark /artifacts route response_class=FileResponse --- .../ota_update_server/ota_update_server/main.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py index 23d1d44..159580c 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py +++ b/demos/ota_nav2_sensor_fix/ota_update_server/ota_update_server/main.py @@ -20,7 +20,7 @@ def catalog() -> list[dict]: return [] return json.loads(catalog_file.read_text()) - @app.get("/artifacts/{filename}") + @app.get("/artifacts/{filename}", response_class=FileResponse) def artifact(filename: str) -> FileResponse: if "/" in filename or ".." in filename: raise HTTPException(status_code=400, detail="invalid filename") From 248ef325376e8385d91c8a9060f5e4b2be1773ac Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:12:20 +0200 Subject: [PATCH 15/36] feat(demos/ota): broken_lidar node with phantom /scan return --- .../ros2_packages/broken_lidar/CMakeLists.txt | 21 +++++++++ .../ros2_packages/broken_lidar/package.xml | 17 +++++++ .../broken_lidar/src/broken_lidar_node.cpp | 44 +++++++++++++++++++ 3 files changed, 82 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/CMakeLists.txt create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/package.xml create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/src/broken_lidar_node.cpp diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/CMakeLists.txt new file mode 100644 index 0000000..9ecd54e --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.16) +project(broken_lidar) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rclcpp REQUIRED) +find_package(sensor_msgs REQUIRED) + +add_executable(broken_lidar_node src/broken_lidar_node.cpp) +ament_target_dependencies(broken_lidar_node rclcpp sensor_msgs) + +install(TARGETS broken_lidar_node DESTINATION lib/${PROJECT_NAME}) + +ament_package() diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/package.xml b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/package.xml new file mode 100644 index 0000000..1b4ab49 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/package.xml @@ -0,0 +1,17 @@ + + + broken_lidar + 1.0.0 + Broken lidar node that publishes /scan with a phantom obstacle (demo target of OTA update). + bburda + Apache-2.0 + + ament_cmake + + rclcpp + sensor_msgs + + + ament_cmake + + diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/src/broken_lidar_node.cpp b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/src/broken_lidar_node.cpp new file mode 100644 index 0000000..856f760 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar/src/broken_lidar_node.cpp @@ -0,0 +1,44 @@ +// Copyright 2026 bburda. Apache-2.0. +#include +#include +#include + +#include +#include + +using std::chrono_literals::operator""ms; + +class BrokenLidarNode : public rclcpp::Node { + public: + BrokenLidarNode() : Node("scan_sensor_node") { + pub_ = create_publisher("scan", 10); + timer_ = create_wall_timer(100ms, [this]() { publish_scan(); }); + } + + private: + void publish_scan() { + sensor_msgs::msg::LaserScan msg; + msg.header.stamp = now(); + msg.header.frame_id = "base_scan"; + msg.angle_min = -static_cast(M_PI); + msg.angle_max = static_cast(M_PI); + msg.angle_increment = static_cast(M_PI / 180.0); + msg.range_min = 0.05f; + msg.range_max = 10.0f; + constexpr int kRays = 360; + msg.ranges.assign(kRays, msg.range_max); + // Inject a 1 m phantom return at angle 0 (straight ahead, ray index 180) + msg.ranges[180] = 1.0f; + pub_->publish(msg); + } + + rclcpp::Publisher::SharedPtr pub_; + rclcpp::TimerBase::SharedPtr timer_; +}; + +int main(int argc, char ** argv) { + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} From c8e10eb1348240304ae46b30b9b5e8f12a989865 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:13:10 +0200 Subject: [PATCH 16/36] feat(demos/ota): fixed_lidar (clean /scan, no phantom) --- .../ros2_packages/fixed_lidar/CMakeLists.txt | 21 ++++++++++ .../ros2_packages/fixed_lidar/package.xml | 17 ++++++++ .../fixed_lidar/src/fixed_lidar_node.cpp | 42 +++++++++++++++++++ 3 files changed, 80 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/CMakeLists.txt create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/package.xml create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/src/fixed_lidar_node.cpp diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/CMakeLists.txt new file mode 100644 index 0000000..d08580f --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/CMakeLists.txt @@ -0,0 +1,21 @@ +cmake_minimum_required(VERSION 3.16) +project(fixed_lidar) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rclcpp REQUIRED) +find_package(sensor_msgs REQUIRED) + +add_executable(fixed_lidar_node src/fixed_lidar_node.cpp) +ament_target_dependencies(fixed_lidar_node rclcpp sensor_msgs) + +install(TARGETS fixed_lidar_node DESTINATION lib/${PROJECT_NAME}) + +ament_package() diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/package.xml b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/package.xml new file mode 100644 index 0000000..d0315d7 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/package.xml @@ -0,0 +1,17 @@ + + + fixed_lidar + 2.1.0 + Fixed lidar node that publishes clean /scan. + bburda + Apache-2.0 + + ament_cmake + + rclcpp + sensor_msgs + + + ament_cmake + + diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/src/fixed_lidar_node.cpp b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/src/fixed_lidar_node.cpp new file mode 100644 index 0000000..587b97c --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/fixed_lidar/src/fixed_lidar_node.cpp @@ -0,0 +1,42 @@ +// Copyright 2026 bburda. Apache-2.0. +#include +#include +#include + +#include +#include + +using std::chrono_literals::operator""ms; + +class FixedLidarNode : public rclcpp::Node { + public: + FixedLidarNode() : Node("scan_sensor_node") { + pub_ = create_publisher("scan", 10); + timer_ = create_wall_timer(100ms, [this]() { publish_scan(); }); + } + + private: + void publish_scan() { + sensor_msgs::msg::LaserScan msg; + msg.header.stamp = now(); + msg.header.frame_id = "base_scan"; + msg.angle_min = -static_cast(M_PI); + msg.angle_max = static_cast(M_PI); + msg.angle_increment = static_cast(M_PI / 180.0); + msg.range_min = 0.05f; + msg.range_max = 10.0f; + constexpr int kRays = 360; + msg.ranges.assign(kRays, msg.range_max); + pub_->publish(msg); + } + + rclcpp::Publisher::SharedPtr pub_; + rclcpp::TimerBase::SharedPtr timer_; +}; + +int main(int argc, char ** argv) { + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} From a14243b30a8ba6d0f3e5f109b5db72f6c2c231a8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:13:54 +0200 Subject: [PATCH 17/36] feat(demos/ota): broken_lidar_legacy do-nothing node (uninstall target) --- .../broken_lidar_legacy/CMakeLists.txt | 20 ++++++++++++++++ .../broken_lidar_legacy/package.xml | 16 +++++++++++++ .../broken_lidar_legacy/src/legacy_node.cpp | 24 +++++++++++++++++++ 3 files changed, 60 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/CMakeLists.txt create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/package.xml create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/src/legacy_node.cpp diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/CMakeLists.txt new file mode 100644 index 0000000..eda65f7 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/CMakeLists.txt @@ -0,0 +1,20 @@ +cmake_minimum_required(VERSION 3.16) +project(broken_lidar_legacy) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rclcpp REQUIRED) + +add_executable(broken_lidar_legacy src/legacy_node.cpp) +ament_target_dependencies(broken_lidar_legacy rclcpp) + +install(TARGETS broken_lidar_legacy DESTINATION lib/${PROJECT_NAME}) + +ament_package() diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/package.xml b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/package.xml new file mode 100644 index 0000000..2cd0f61 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/package.xml @@ -0,0 +1,16 @@ + + + broken_lidar_legacy + 1.0.0 + Do-nothing legacy package, target of uninstall demo scene. + bburda + Apache-2.0 + + ament_cmake + + rclcpp + + + ament_cmake + + diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/src/legacy_node.cpp b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/src/legacy_node.cpp new file mode 100644 index 0000000..144b7b4 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/broken_lidar_legacy/src/legacy_node.cpp @@ -0,0 +1,24 @@ +// Copyright 2026 bburda. Apache-2.0. +#include +#include + +#include + +using std::chrono_literals::operator""s; + +class LegacyNode : public rclcpp::Node { + public: + LegacyNode() : Node("broken_lidar_legacy") { + timer_ = create_wall_timer(5s, []() {}); + } + + private: + rclcpp::TimerBase::SharedPtr timer_; +}; + +int main(int argc, char ** argv) { + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} From 01e5fc84e89c4e5d29ab5c54da6fb847e4664139 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:14:45 +0200 Subject: [PATCH 18/36] feat(demos/ota): obstacle_classifier_v2 (install target, /scan -> /safety_overlay) --- .../obstacle_classifier_v2/CMakeLists.txt | 22 ++++++++ .../obstacle_classifier_v2/package.xml | 18 +++++++ .../src/obstacle_classifier_node.cpp | 50 +++++++++++++++++++ 3 files changed, 90 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/CMakeLists.txt create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/package.xml create mode 100644 demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/src/obstacle_classifier_node.cpp diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/CMakeLists.txt new file mode 100644 index 0000000..cda1852 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/CMakeLists.txt @@ -0,0 +1,22 @@ +cmake_minimum_required(VERSION 3.16) +project(obstacle_classifier_v2) + +if(NOT CMAKE_CXX_STANDARD) + set(CMAKE_CXX_STANDARD 17) +endif() + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +find_package(ament_cmake REQUIRED) +find_package(rclcpp REQUIRED) +find_package(sensor_msgs REQUIRED) +find_package(visualization_msgs REQUIRED) + +add_executable(obstacle_classifier_node src/obstacle_classifier_node.cpp) +ament_target_dependencies(obstacle_classifier_node rclcpp sensor_msgs visualization_msgs) + +install(TARGETS obstacle_classifier_node DESTINATION lib/${PROJECT_NAME}) + +ament_package() diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/package.xml b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/package.xml new file mode 100644 index 0000000..6d214bd --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/package.xml @@ -0,0 +1,18 @@ + + + obstacle_classifier_v2 + 1.0.0 + Extra safety layer for nav2 (target of install demo scene). + bburda + Apache-2.0 + + ament_cmake + + rclcpp + sensor_msgs + visualization_msgs + + + ament_cmake + + diff --git a/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/src/obstacle_classifier_node.cpp b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/src/obstacle_classifier_node.cpp new file mode 100644 index 0000000..914c689 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_packages/obstacle_classifier_v2/src/obstacle_classifier_node.cpp @@ -0,0 +1,50 @@ +// Copyright 2026 bburda. Apache-2.0. +#include + +#include +#include +#include + +class ObstacleClassifierNode : public rclcpp::Node { + public: + ObstacleClassifierNode() : Node("obstacle_classifier") { + pub_ = create_publisher("safety_overlay", 10); + sub_ = create_subscription( + "scan", 10, + [this](sensor_msgs::msg::LaserScan::SharedPtr msg) { on_scan(*msg); }); + } + + private: + void on_scan(const sensor_msgs::msg::LaserScan & scan) { + visualization_msgs::msg::MarkerArray markers; + visualization_msgs::msg::Marker m; + m.header = scan.header; + m.ns = "safety_overlay"; + m.id = 0; + m.type = visualization_msgs::msg::Marker::CYLINDER; + m.action = visualization_msgs::msg::Marker::ADD; + m.scale.x = 0.4; + m.scale.y = 0.4; + m.scale.z = 0.4; + m.color.r = 0.0f; + m.color.g = 1.0f; + m.color.b = 0.4f; + m.color.a = 0.6f; + m.pose.position.x = 0.0; + m.pose.position.y = 0.0; + m.pose.position.z = 0.2; + m.pose.orientation.w = 1.0; + markers.markers.push_back(m); + pub_->publish(markers); + } + + rclcpp::Publisher::SharedPtr pub_; + rclcpp::Subscription::SharedPtr sub_; +}; + +int main(int argc, char ** argv) { + rclcpp::init(argc, argv); + rclcpp::spin(std::make_shared()); + rclcpp::shutdown(); + return 0; +} From 9defc1a1b84f2ad54ba0e88708ec49079acc00d4 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:15:59 +0200 Subject: [PATCH 19/36] feat(demos/ota): build_artifacts.sh + gitignore generated tarballs --- .../ota_nav2_sensor_fix/artifacts/.gitignore | 2 + demos/ota_nav2_sensor_fix/ros2_ws/.gitignore | 4 ++ .../scripts/build_artifacts.sh | 51 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/artifacts/.gitignore create mode 100644 demos/ota_nav2_sensor_fix/ros2_ws/.gitignore create mode 100755 demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh diff --git a/demos/ota_nav2_sensor_fix/artifacts/.gitignore b/demos/ota_nav2_sensor_fix/artifacts/.gitignore new file mode 100644 index 0000000..ae78201 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/artifacts/.gitignore @@ -0,0 +1,2 @@ +*.tar.gz +catalog.json diff --git a/demos/ota_nav2_sensor_fix/ros2_ws/.gitignore b/demos/ota_nav2_sensor_fix/ros2_ws/.gitignore new file mode 100644 index 0000000..fb3674e --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ros2_ws/.gitignore @@ -0,0 +1,4 @@ +build/ +install/ +log/ +src/ diff --git a/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh new file mode 100755 index 0000000..e13ae6f --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +set -eo pipefail +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +DEMO_DIR="$(dirname "$SCRIPT_DIR")" +WS="$DEMO_DIR/ros2_ws" +ARTIFACTS="$DEMO_DIR/artifacts" + +# shellcheck disable=SC1091 +source /opt/ros/jazzy/setup.bash +set -u + +mkdir -p "$WS/src" +for pkg in broken_lidar fixed_lidar broken_lidar_legacy obstacle_classifier_v2; do + ln -sfn "$DEMO_DIR/ros2_packages/$pkg" "$WS/src/$pkg" +done + +(cd "$WS" && colcon build --packages-select fixed_lidar obstacle_classifier_v2) + +mkdir -p "$ARTIFACTS" +rm -f "$ARTIFACTS/catalog.json" + +PACK="$SCRIPT_DIR/.venv/bin/python $SCRIPT_DIR/pack_artifact.py" + +env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ + --package fixed_lidar --version 2.1.0 \ + --kind update --target-component scan_sensor_node \ + --executable fixed_lidar_node \ + --notes "Fix /scan noise filter" \ + --skip-build --workspace "$WS" \ + --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" + +env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ + --package obstacle_classifier_v2 --version 1.0.0 \ + --kind install --target-component obstacle_classifier \ + --executable obstacle_classifier_node \ + --notes "Extra safety layer for nav2" \ + --skip-build --workspace "$WS" \ + --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" + +env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ + --package broken_lidar_legacy --version "" \ + --kind uninstall --target-component broken_lidar_legacy \ + --notes "Clean up deprecated package" \ + --skip-build --workspace "$WS" \ + --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" + +if command -v jq >/dev/null 2>&1; then + echo "Built catalog with $(jq length "$ARTIFACTS/catalog.json") entries" +else + echo "Built catalog: $(wc -l < "$ARTIFACTS/catalog.json") lines" +fi From 706c62c4fc736f5d08a46d6e3b419b23e6b16186 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:21:43 +0200 Subject: [PATCH 20/36] fix(demos/ota): use array for pack_artifact invocation in build script --- demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh index e13ae6f..6588466 100755 --- a/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh +++ b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh @@ -19,9 +19,9 @@ done mkdir -p "$ARTIFACTS" rm -f "$ARTIFACTS/catalog.json" -PACK="$SCRIPT_DIR/.venv/bin/python $SCRIPT_DIR/pack_artifact.py" +PACK=("$SCRIPT_DIR/.venv/bin/python" "$SCRIPT_DIR/pack_artifact.py") -env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ +env -i PATH=/usr/bin:/bin HOME="$HOME" "${PACK[@]}" \ --package fixed_lidar --version 2.1.0 \ --kind update --target-component scan_sensor_node \ --executable fixed_lidar_node \ @@ -29,7 +29,7 @@ env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ --skip-build --workspace "$WS" \ --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" -env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ +env -i PATH=/usr/bin:/bin HOME="$HOME" "${PACK[@]}" \ --package obstacle_classifier_v2 --version 1.0.0 \ --kind install --target-component obstacle_classifier \ --executable obstacle_classifier_node \ @@ -37,7 +37,7 @@ env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ --skip-build --workspace "$WS" \ --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" -env -i PATH=/usr/bin:/bin HOME="$HOME" $PACK \ +env -i PATH=/usr/bin:/bin HOME="$HOME" "${PACK[@]}" \ --package broken_lidar_legacy --version "" \ --kind uninstall --target-component broken_lidar_legacy \ --notes "Clean up deprecated package" \ From 1a9f20a147bf12a740a75a9470861c9b60930b0d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:32:43 +0200 Subject: [PATCH 21/36] feat(demos/ota): ota_update_plugin C++ gateway plugin Implements GatewayPlugin + UpdateProvider for the OTA demo. Polls a FastAPI catalog at boot and supports update / install / uninstall operations derived from SOVD ISO 17978-3 metadata. Process model: SIGTERM old executable, swap files on disk, fork+exec new executable. No lifecycle commands. Operation kind is classified from updated_components / added_components / removed_components. Components: - OtaUpdatePlugin: list/get/register/delete/prepare/execute/supports_automated - CatalogClient: cpp-httplib GET /catalog and artifact download, with parse_url - OperationDispatcher: SOVD metadata -> Update/Install/Uninstall/Unknown - ProcessRunner: pgrep via /proc, kill_by_executable with SIGTERM->SIGKILL fallback, fork+exec spawn 21 gtests pass (7 dispatcher, 6 parse_url, 8 plugin smoke). --- .../ota_update_plugin/CMakeLists.txt | 86 +++++ .../ota_update_plugin/ota_update_plugin.hpp | 83 +++++ .../ota_update_plugin/package.xml | 20 ++ .../ota_update_plugin/src/catalog_client.cpp | 171 ++++++++++ .../ota_update_plugin/src/catalog_client.hpp | 61 ++++ .../src/operation_dispatcher.cpp | 48 +++ .../src/operation_dispatcher.hpp | 33 ++ .../src/ota_update_plugin.cpp | 294 ++++++++++++++++++ .../ota_update_plugin/src/plugin_exports.cpp | 25 ++ .../ota_update_plugin/src/process_runner.cpp | 130 ++++++++ .../ota_update_plugin/src/process_runner.hpp | 48 +++ .../test/test_catalog_client.cpp | 59 ++++ .../test/test_operation_dispatcher.cpp | 60 ++++ .../test/test_plugin_smoke.cpp | 141 +++++++++ 14 files changed, 1259 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/package.xml create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.hpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.hpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.hpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_catalog_client.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_operation_dispatcher.cpp create mode 100644 demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt new file mode 100644 index 0000000..9798bd7 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt @@ -0,0 +1,86 @@ +# 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 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +cmake_minimum_required(VERSION 3.16) +project(ota_update_plugin CXX) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic) +endif() + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +find_package(ament_cmake REQUIRED) +find_package(ros2_medkit_cmake REQUIRED) +include(ROS2MedkitCompat) +find_package(ros2_medkit_gateway REQUIRED) +find_package(nlohmann_json REQUIRED) + +# CatalogClient uses cpp-httplib for HTTP. Use gateway's vendored copy as fallback. +set(_gw_vendored "${ros2_medkit_gateway_DIR}/../vendored/cpp_httplib") +medkit_find_cpp_httplib(VENDORED_DIR "${_gw_vendored}") +unset(_gw_vendored) + +# Static core library: plugin + tests both link against this. +add_library(ota_update_plugin_core STATIC + src/ota_update_plugin.cpp + src/catalog_client.cpp + src/operation_dispatcher.cpp + src/process_runner.cpp +) +target_include_directories(ota_update_plugin_core + PUBLIC + $ + $ +) +ament_target_dependencies(ota_update_plugin_core ros2_medkit_gateway) +target_link_libraries(ota_update_plugin_core + PUBLIC + nlohmann_json::nlohmann_json + cpp_httplib_target +) +set_target_properties(ota_update_plugin_core PROPERTIES POSITION_INDEPENDENT_CODE ON) + +# MODULE target: loaded via dlopen at runtime by PluginManager. +# Symbols from gateway_lib are resolved from the host process at runtime. +add_library(ota_update_plugin MODULE src/plugin_exports.cpp) +target_link_libraries(ota_update_plugin PRIVATE ota_update_plugin_core) +set_target_properties(ota_update_plugin PROPERTIES + PREFIX "" + OUTPUT_NAME "ota_update_plugin" +) +# Allow unresolved symbols - they resolve from the host process at runtime +target_link_options(ota_update_plugin PRIVATE + -Wl,--unresolved-symbols=ignore-all +) + +install(TARGETS ota_update_plugin + LIBRARY DESTINATION lib/${PROJECT_NAME} +) +install(DIRECTORY include/ DESTINATION include) + +if(BUILD_TESTING) + find_package(ament_cmake_gtest REQUIRED) + ament_add_gtest(test_ota_update_plugin + test/test_operation_dispatcher.cpp + test/test_catalog_client.cpp + test/test_plugin_smoke.cpp + ) + target_link_libraries(test_ota_update_plugin ota_update_plugin_core) + target_include_directories(test_ota_update_plugin PRIVATE src) +endif() + +ament_package() diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp new file mode 100644 index 0000000..adb1b49 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp @@ -0,0 +1,83 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +#include +#include +#include + +namespace ota_update_plugin { + +class CatalogClient; +class ProcessRunner; + +/// OTA update plugin: implements both GatewayPlugin and UpdateProvider. +/// Polls a FastAPI catalog at boot and supports update / install / uninstall +/// operations derived from SOVD ISO 17978-3 metadata. +class OtaUpdatePlugin : public ros2_medkit_gateway::GatewayPlugin, public ros2_medkit_gateway::UpdateProvider { + public: + OtaUpdatePlugin(); + ~OtaUpdatePlugin() override; + + OtaUpdatePlugin(const OtaUpdatePlugin &) = delete; + OtaUpdatePlugin & operator=(const OtaUpdatePlugin &) = delete; + OtaUpdatePlugin(OtaUpdatePlugin &&) = delete; + OtaUpdatePlugin & operator=(OtaUpdatePlugin &&) = delete; + + // GatewayPlugin + std::string name() const override { + return "ota_update_plugin"; + } + void configure(const nlohmann::json & config) override; + void set_context(ros2_medkit_gateway::PluginContext & context) override; + + // UpdateProvider + tl::expected, ros2_medkit_gateway::UpdateBackendErrorInfo> list_updates( + const ros2_medkit_gateway::UpdateFilter & filter) override; + tl::expected get_update(const std::string & id) override; + tl::expected register_update( + const nlohmann::json & metadata) override; + tl::expected delete_update(const std::string & id) override; + tl::expected prepare( + const std::string & id, ros2_medkit_gateway::UpdateProgressReporter & reporter) override; + tl::expected execute( + const std::string & id, ros2_medkit_gateway::UpdateProgressReporter & reporter) override; + tl::expected supports_automated(const std::string & id) override; + + // Test seams + void set_catalog_client_for_test(std::unique_ptr client); + void set_process_runner_for_test(std::unique_ptr runner); + void poll_and_register_catalog(); + + private: + std::string catalog_url_; + std::string staging_dir_; + std::string install_dir_; + + std::mutex mu_; + std::map registry_; + std::map staged_artifacts_; + + std::unique_ptr catalog_client_; + std::unique_ptr process_runner_; +}; + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/package.xml b/demos/ota_nav2_sensor_fix/ota_update_plugin/package.xml new file mode 100644 index 0000000..200c826 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/package.xml @@ -0,0 +1,20 @@ + + + ota_update_plugin + 0.1.0 + Dev-grade OTA plugin for ros2_medkit gateway: update / install / uninstall via simple HTTP catalog. + bburda + Apache-2.0 + + ament_cmake + + ros2_medkit_cmake + ros2_medkit_gateway + nlohmann-json-dev + + ament_cmake_gtest + + + ament_cmake + + diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.cpp new file mode 100644 index 0000000..1849025 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.cpp @@ -0,0 +1,171 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "catalog_client.hpp" + +#include +#include +#include + +#include + +namespace ota_update_plugin { + +namespace { + +bool starts_with(const std::string & s, const std::string & prefix) { + return s.size() >= prefix.size() && s.compare(0, prefix.size(), prefix) == 0; +} + +} // namespace + +ParsedUrl parse_url(const std::string & url) { + ParsedUrl out{}; + std::string rest; + if (starts_with(url, "https://")) { + out.tls = true; + out.port = 443; + rest = url.substr(8); + } else if (starts_with(url, "http://")) { + out.tls = false; + out.port = 80; + rest = url.substr(7); + } else { + throw std::invalid_argument("unsupported URL scheme: " + url); + } + + // Split host[:port] from path. + const auto slash = rest.find('/'); + std::string authority; + if (slash == std::string::npos) { + authority = rest; + out.path = "/"; + } else { + authority = rest.substr(0, slash); + out.path = rest.substr(slash); + } + + // Split host from port if present. + const auto colon = authority.find(':'); + if (colon == std::string::npos) { + out.host = authority; + } else { + out.host = authority.substr(0, colon); + try { + out.port = std::stoi(authority.substr(colon + 1)); + } catch (const std::exception & e) { + throw std::invalid_argument(std::string("invalid port in URL: ") + url + " (" + e.what() + ")"); + } + } + + if (out.host.empty()) { + throw std::invalid_argument("missing host in URL: " + url); + } + return out; +} + +CatalogClient::CatalogClient(std::string base_url) : base_url_(std::move(base_url)) { +} + +tl::expected CatalogClient::fetch_catalog() { + ParsedUrl parsed; + try { + parsed = parse_url(base_url_); + } catch (const std::exception & e) { + return tl::make_unexpected(std::string("invalid catalog url: ") + e.what()); + } + + if (parsed.tls) { + return tl::make_unexpected("https not supported by demo CatalogClient"); + } + + // Strip trailing slash from base path, then append /catalog. + std::string base_path = parsed.path; + if (!base_path.empty() && base_path.back() == '/') { + base_path.pop_back(); + } + const std::string target = base_path + "/catalog"; + + httplib::Client cli(parsed.host, parsed.port); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(5, 0); + + auto res = cli.Get(target.c_str()); + if (!res) { + return tl::make_unexpected("catalog GET failed: " + httplib::to_string(res.error())); + } + if (res->status < 200 || res->status >= 300) { + return tl::make_unexpected("catalog GET returned status " + std::to_string(res->status)); + } + try { + return nlohmann::json::parse(res->body); + } catch (const std::exception & e) { + return tl::make_unexpected(std::string("catalog json parse failed: ") + e.what()); + } +} + +tl::expected CatalogClient::download_artifact(const std::string & url_or_path, + const std::string & out_path) { + // If url_or_path is an absolute URL, parse it directly. Otherwise treat as a + // path relative to base_url_. + std::string full_url; + if (starts_with(url_or_path, "http://") || starts_with(url_or_path, "https://")) { + full_url = url_or_path; + } else { + std::string base = base_url_; + // Strip trailing slash on base, leading slash on relative path. + while (!base.empty() && base.back() == '/') { + base.pop_back(); + } + std::string rel = url_or_path; + if (rel.empty() || rel.front() != '/') { + rel = "/" + rel; + } + full_url = base + rel; + } + + ParsedUrl parsed; + try { + parsed = parse_url(full_url); + } catch (const std::exception & e) { + return tl::make_unexpected(std::string("invalid artifact url: ") + e.what()); + } + if (parsed.tls) { + return tl::make_unexpected("https not supported by demo CatalogClient"); + } + + httplib::Client cli(parsed.host, parsed.port); + cli.set_connection_timeout(5, 0); + cli.set_read_timeout(30, 0); + + auto res = cli.Get(parsed.path.c_str()); + if (!res) { + return tl::make_unexpected("artifact GET failed: " + httplib::to_string(res.error())); + } + if (res->status < 200 || res->status >= 300) { + return tl::make_unexpected("artifact GET returned status " + std::to_string(res->status)); + } + + std::ofstream o(out_path, std::ios::binary); + if (!o) { + return tl::make_unexpected("cannot open output file: " + out_path); + } + o.write(res->body.data(), static_cast(res->body.size())); + if (!o) { + return tl::make_unexpected("write to output file failed: " + out_path); + } + return out_path; +} + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.hpp new file mode 100644 index 0000000..0b8a9b2 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/catalog_client.hpp @@ -0,0 +1,61 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +#include +#include + +namespace ota_update_plugin { + +/// Decomposed URL components used by the HTTP client. +struct ParsedUrl { + std::string host; + int port; + bool tls; + std::string path; +}; + +/// Parse an http:// or https:// URL into components. +/// Throws std::invalid_argument for unsupported schemes. +ParsedUrl parse_url(const std::string & url); + +/// HTTP client that fetches the FastAPI catalog and downloads artifacts. +/// Virtual methods so tests can substitute a fake without touching real HTTP. +class CatalogClient { + public: + explicit CatalogClient(std::string base_url); + virtual ~CatalogClient() = default; + + CatalogClient(const CatalogClient &) = delete; + CatalogClient & operator=(const CatalogClient &) = delete; + CatalogClient(CatalogClient &&) = delete; + CatalogClient & operator=(CatalogClient &&) = delete; + + /// GET {base_url}/catalog and parse JSON. Returns the JSON array on success. + virtual tl::expected fetch_catalog(); + + /// Download an artifact. `url_or_path` may be either an absolute URL or a + /// path (interpreted relative to `base_url`). Body is written to `out_path`. + /// Returns the absolute output path on success. + virtual tl::expected download_artifact(const std::string & url_or_path, + const std::string & out_path); + + protected: + std::string base_url_; +}; + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.cpp new file mode 100644 index 0000000..b28b2af --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.cpp @@ -0,0 +1,48 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "operation_dispatcher.hpp" + +namespace ota_update_plugin { + +namespace { + +bool non_empty_array(const nlohmann::json & j, const char * key) { + if (!j.contains(key)) { + return false; + } + const auto & v = j.at(key); + return v.is_array() && !v.empty(); +} + +} // namespace + +OperationKind OperationDispatcher::classify(const nlohmann::json & metadata) { + const bool has_updated = non_empty_array(metadata, "updated_components"); + const bool has_added = non_empty_array(metadata, "added_components"); + const bool has_removed = non_empty_array(metadata, "removed_components"); + const int populated = static_cast(has_updated) + static_cast(has_added) + static_cast(has_removed); + if (populated != 1) { + return OperationKind::Unknown; + } + if (has_updated) { + return OperationKind::Update; + } + if (has_added) { + return OperationKind::Install; + } + return OperationKind::Uninstall; +} + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.hpp new file mode 100644 index 0000000..3398c2e --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/operation_dispatcher.hpp @@ -0,0 +1,33 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include + +namespace ota_update_plugin { + +/// Operation kind classified from SOVD update metadata. +enum class OperationKind { Update, Install, Uninstall, Unknown }; + +/// Maps SOVD update metadata to a concrete operation kind based on which of +/// updated_components / added_components / removed_components is populated. +class OperationDispatcher { + public: + /// Classify an update's operation kind. Returns Unknown if zero or more + /// than one of the three component arrays is non-empty. + static OperationKind classify(const nlohmann::json & metadata); +}; + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp new file mode 100644 index 0000000..6b170c2 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp @@ -0,0 +1,294 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ota_update_plugin/ota_update_plugin.hpp" + +#include +#include +#include +#include + +#include "catalog_client.hpp" +#include "operation_dispatcher.hpp" +#include "process_runner.hpp" + +namespace ota_update_plugin { + +namespace fs = std::filesystem; +using ros2_medkit_gateway::UpdateBackendError; +using ros2_medkit_gateway::UpdateBackendErrorInfo; + +namespace { + +/// Extract a packed tarball into a staging directory, then atomically replace +/// `${install_dir}/${target_package}` with the freshly extracted contents. +/// The artifacts are produced by pack_artifact.py and contain a single +/// top-level directory named after the target package. +tl::expected extract_and_swap(const std::string & staged_tarball, const std::string & install_dir, + const std::string & target_package) { + if (target_package.empty()) { + return tl::make_unexpected("target_package is empty"); + } + const std::string staging_extracted = staged_tarball + ".extracted"; + std::error_code ec; + fs::remove_all(staging_extracted, ec); + fs::create_directories(staging_extracted, ec); + + const std::string cmd = "tar -xzf " + staged_tarball + " -C " + staging_extracted; + if (std::system(cmd.c_str()) != 0) { + return tl::make_unexpected("tar extraction failed: " + cmd); + } + + const std::string source = staging_extracted + "/" + target_package; + if (!fs::exists(source)) { + return tl::make_unexpected("artifact missing top-level directory '" + target_package + "' after extraction"); + } + + fs::create_directories(install_dir, ec); + const std::string target = install_dir + "/" + target_package; + fs::remove_all(target, ec); + fs::copy(source, target, fs::copy_options::recursive | fs::copy_options::overwrite_existing, ec); + if (ec) { + return tl::make_unexpected("copy failed: " + ec.message()); + } + return {}; +} + +} // namespace + +OtaUpdatePlugin::OtaUpdatePlugin() : process_runner_(std::make_unique()) { +} + +OtaUpdatePlugin::~OtaUpdatePlugin() = default; + +void OtaUpdatePlugin::configure(const nlohmann::json & config) { + catalog_url_ = config.value("catalog_url", "http://ota_update_server:9000"); + staging_dir_ = config.value("staging_dir", "/tmp/ota_staging"); + install_dir_ = config.value("install_dir", "/ws/install"); +} + +void OtaUpdatePlugin::set_context(ros2_medkit_gateway::PluginContext & /*context*/) { + poll_and_register_catalog(); +} + +void OtaUpdatePlugin::poll_and_register_catalog() { + if (!catalog_client_) { + catalog_client_ = std::make_unique(catalog_url_); + } + auto fetched = catalog_client_->fetch_catalog(); + if (!fetched) { + std::fprintf(stderr, "[ota_update_plugin] catalog fetch failed: %s\n", fetched.error().c_str()); + return; + } + if (!fetched->is_array()) { + std::fprintf(stderr, "[ota_update_plugin] catalog payload is not an array\n"); + return; + } + for (const auto & entry : *fetched) { + auto rc = register_update(entry); + if (!rc) { + const std::string id = entry.value("id", "?"); + std::fprintf(stderr, "[ota_update_plugin] register %s failed: %s\n", id.c_str(), rc.error().message.c_str()); + } + } +} + +void OtaUpdatePlugin::set_catalog_client_for_test(std::unique_ptr client) { + catalog_client_ = std::move(client); +} + +void OtaUpdatePlugin::set_process_runner_for_test(std::unique_ptr runner) { + process_runner_ = std::move(runner); +} + +tl::expected, UpdateBackendErrorInfo> OtaUpdatePlugin::list_updates( + const ros2_medkit_gateway::UpdateFilter & /*filter*/) { + std::lock_guard lk(mu_); + std::vector ids; + ids.reserve(registry_.size()); + for (const auto & kv : registry_) { + ids.push_back(kv.first); + } + return ids; +} + +tl::expected OtaUpdatePlugin::get_update(const std::string & id) { + std::lock_guard lk(mu_); + auto it = registry_.find(id); + if (it == registry_.end()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::NotFound, "update not registered"}); + } + return it->second; +} + +tl::expected OtaUpdatePlugin::register_update(const nlohmann::json & metadata) { + if (!metadata.contains("id") || !metadata["id"].is_string()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "metadata missing id"}); + } + std::lock_guard lk(mu_); + registry_[metadata["id"].get()] = metadata; + return {}; +} + +tl::expected OtaUpdatePlugin::delete_update(const std::string & id) { + std::lock_guard lk(mu_); + registry_.erase(id); + staged_artifacts_.erase(id); + return {}; +} + +tl::expected OtaUpdatePlugin::prepare( + const std::string & id, ros2_medkit_gateway::UpdateProgressReporter & reporter) { + nlohmann::json metadata; + { + std::lock_guard lk(mu_); + auto it = registry_.find(id); + if (it == registry_.end()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::NotFound, "no such update"}); + } + metadata = it->second; + } + + const auto kind = OperationDispatcher::classify(metadata); + if (kind == OperationKind::Unknown) { + return tl::make_unexpected(UpdateBackendErrorInfo{ + UpdateBackendError::InvalidInput, + "update package must populate exactly one of " + "updated_components / added_components / removed_components"}); + } + + if (kind == OperationKind::Uninstall) { + reporter.set_progress(100); + return {}; + } + + if (!metadata.contains("x_medkit_artifact_url") || !metadata["x_medkit_artifact_url"].is_string()) { + return tl::make_unexpected( + UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "missing x_medkit_artifact_url"}); + } + + std::error_code ec; + fs::create_directories(staging_dir_, ec); + const std::string url = metadata["x_medkit_artifact_url"].get(); + const std::string staged_path = staging_dir_ + "/" + id + ".tar.gz"; + + reporter.set_progress(10); + if (!catalog_client_) { + catalog_client_ = std::make_unique(catalog_url_); + } + auto dl = catalog_client_->download_artifact(url, staged_path); + if (!dl) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "download failed: " + dl.error()}); + } + reporter.set_progress(80); + + { + std::lock_guard lk(mu_); + staged_artifacts_[id] = *dl; + } + reporter.set_progress(100); + return {}; +} + +tl::expected OtaUpdatePlugin::execute( + const std::string & id, ros2_medkit_gateway::UpdateProgressReporter & reporter) { + nlohmann::json metadata; + std::string staged; + { + std::lock_guard lk(mu_); + auto it = registry_.find(id); + if (it == registry_.end()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::NotFound, "no such update"}); + } + metadata = it->second; + auto sit = staged_artifacts_.find(id); + staged = (sit != staged_artifacts_.end()) ? sit->second : ""; + } + + const auto kind = OperationDispatcher::classify(metadata); + const std::string target_package = metadata.value("x_medkit_target_package", ""); + const std::string executable = metadata.value("x_medkit_executable", ""); + + if (kind == OperationKind::Update) { + if (staged.empty()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "call prepare() first"}); + } + if (executable.empty()) { + return tl::make_unexpected( + UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "missing x_medkit_executable"}); + } + reporter.set_progress(20); + auto kr = process_runner_->kill_by_executable(executable); + if (!kr) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "kill failed: " + kr.error()}); + } + reporter.set_progress(40); + if (auto sw = extract_and_swap(staged, install_dir_, target_package); !sw) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "swap failed: " + sw.error()}); + } + reporter.set_progress(70); + const std::string bin = install_dir_ + "/" + target_package + "/lib/" + target_package + "/" + executable; + auto sp = process_runner_->spawn(bin); + if (!sp) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "spawn failed: " + sp.error()}); + } + reporter.set_progress(100); + return {}; + } + + if (kind == OperationKind::Install) { + if (staged.empty()) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "call prepare() first"}); + } + if (executable.empty()) { + return tl::make_unexpected( + UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "missing x_medkit_executable"}); + } + reporter.set_progress(30); + if (auto sw = extract_and_swap(staged, install_dir_, target_package); !sw) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "swap failed: " + sw.error()}); + } + reporter.set_progress(70); + const std::string bin = install_dir_ + "/" + target_package + "/lib/" + target_package + "/" + executable; + auto sp = process_runner_->spawn(bin); + if (!sp) { + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "spawn failed: " + sp.error()}); + } + reporter.set_progress(100); + return {}; + } + + if (kind == OperationKind::Uninstall) { + reporter.set_progress(30); + 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); + (void)kr; + reporter.set_progress(70); + std::error_code ec; + fs::remove_all(install_dir_ + "/" + target_package, ec); + } + reporter.set_progress(100); + return {}; + } + + return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "unknown operation kind"}); +} + +tl::expected OtaUpdatePlugin::supports_automated(const std::string & /*id*/) { + return false; +} + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp new file mode 100644 index 0000000..a5916e0 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp @@ -0,0 +1,25 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/plugins/plugin_types.hpp" + +#include "ota_update_plugin/ota_update_plugin.hpp" + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return ros2_medkit_gateway::PLUGIN_API_VERSION; +} + +extern "C" GATEWAY_PLUGIN_EXPORT ros2_medkit_gateway::GatewayPlugin * create_plugin() { + return new ota_update_plugin::OtaUpdatePlugin(); +} diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp new file mode 100644 index 0000000..2c6741f --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp @@ -0,0 +1,130 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "process_runner.hpp" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace ota_update_plugin { + +namespace { + +std::string proc_comm(int pid) { + std::ifstream f("/proc/" + std::to_string(pid) + "/comm"); + if (!f) { + return {}; + } + std::string line; + std::getline(f, line); + return line; +} + +bool is_pid_dir(const char * name) { + for (const char * p = name; *p; ++p) { + if (*p < '0' || *p > '9') { + return false; + } + } + return *name != '\0'; +} + +} // namespace + +std::vector ProcessRunner::pgrep(const std::string & executable_basename) { + std::vector out; + DIR * d = opendir("/proc"); + if (d == nullptr) { + return out; + } + while (auto * ent = readdir(d)) { + if (!is_pid_dir(ent->d_name)) { + continue; + } + const int pid = std::atoi(ent->d_name); + if (pid <= 0) { + continue; + } + if (proc_comm(pid) == executable_basename) { + out.push_back(pid); + } + } + closedir(d); + return out; +} + +tl::expected ProcessRunner::kill_by_executable(const std::string & executable_basename, + int timeout_ms) { + const auto pids = pgrep(executable_basename); + int signalled = 0; + for (int pid : pids) { + if (::kill(pid, SIGTERM) == 0) { + ++signalled; + } + } + if (signalled == 0) { + return 0; + } + + // Poll for exit. + const auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (std::chrono::steady_clock::now() < deadline) { + bool any_alive = false; + for (int pid : pids) { + if (::kill(pid, 0) == 0) { + any_alive = true; + break; + } + } + if (!any_alive) { + return signalled; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + // Force-kill stragglers. + for (int pid : pids) { + if (::kill(pid, 0) == 0) { + ::kill(pid, SIGKILL); + } + } + return signalled; +} + +tl::expected ProcessRunner::spawn(const std::string & executable_path) { + pid_t pid = fork(); + if (pid < 0) { + return tl::make_unexpected(std::string("fork failed: ") + std::strerror(errno)); + } + if (pid == 0) { + // Child: detach from controlling terminal. + setsid(); + execl(executable_path.c_str(), executable_path.c_str(), nullptr); + std::fprintf(stderr, "execl %s failed: %s\n", executable_path.c_str(), std::strerror(errno)); + _exit(127); + } + return static_cast(pid); +} + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.hpp new file mode 100644 index 0000000..70e88cb --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.hpp @@ -0,0 +1,48 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include + +#include + +namespace ota_update_plugin { + +/// Process management helper for OTA operations: locate, terminate, and spawn +/// demo nodes by executable basename. Pure-virtual for test substitution. +class ProcessRunner { + public: + ProcessRunner() = default; + virtual ~ProcessRunner() = default; + + ProcessRunner(const ProcessRunner &) = delete; + ProcessRunner & operator=(const ProcessRunner &) = delete; + ProcessRunner(ProcessRunner &&) = delete; + ProcessRunner & operator=(ProcessRunner &&) = delete; + + /// Find PIDs of processes whose /proc//comm matches the given basename. + virtual std::vector pgrep(const std::string & executable_basename); + + /// Send SIGTERM to all matching PIDs, wait up to `timeout_ms` for exit, then + /// SIGKILL any stragglers. Returns the number of processes that were signalled. + virtual tl::expected kill_by_executable(const std::string & executable_basename, + int timeout_ms = 2000); + + /// fork+exec the executable at `executable_path`. Returns child PID or error. + virtual tl::expected spawn(const std::string & executable_path); +}; + +} // namespace ota_update_plugin diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_catalog_client.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_catalog_client.cpp new file mode 100644 index 0000000..2a671e3 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_catalog_client.cpp @@ -0,0 +1,59 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include + +#include + +#include "catalog_client.hpp" + +using ota_update_plugin::parse_url; + +TEST(ParseUrl, HostAndPort) { + auto p = parse_url("http://server:9000"); + EXPECT_EQ(p.host, "server"); + EXPECT_EQ(p.port, 9000); + EXPECT_FALSE(p.tls); + EXPECT_EQ(p.path, "/"); +} + +TEST(ParseUrl, PathSplit) { + auto p = parse_url("http://server:9000/catalog"); + EXPECT_EQ(p.host, "server"); + EXPECT_EQ(p.port, 9000); + EXPECT_EQ(p.path, "/catalog"); +} + +TEST(ParseUrl, DefaultsHttpPort) { + auto p = parse_url("http://server/catalog"); + EXPECT_EQ(p.host, "server"); + EXPECT_EQ(p.port, 80); + EXPECT_FALSE(p.tls); + EXPECT_EQ(p.path, "/catalog"); +} + +TEST(ParseUrl, HttpsTls) { + auto p = parse_url("https://server/catalog"); + EXPECT_TRUE(p.tls); + EXPECT_EQ(p.port, 443); + EXPECT_EQ(p.path, "/catalog"); +} + +TEST(ParseUrl, RejectsInvalidScheme) { + EXPECT_THROW(parse_url("ftp://server/foo"), std::invalid_argument); +} + +TEST(ParseUrl, RejectsMissingHost) { + EXPECT_THROW(parse_url("http://:9000/foo"), std::invalid_argument); +} diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_operation_dispatcher.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_operation_dispatcher.cpp new file mode 100644 index 0000000..453346a --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_operation_dispatcher.cpp @@ -0,0 +1,60 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include + +#include "operation_dispatcher.hpp" + +using ota_update_plugin::OperationDispatcher; +using ota_update_plugin::OperationKind; + +TEST(OperationDispatcher, UpdateFromUpdatedComponents) { + nlohmann::json m = {{"id", "x"}, {"updated_components", {"scan_sensor_node"}}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Update); +} + +TEST(OperationDispatcher, InstallFromAddedComponents) { + nlohmann::json m = {{"id", "x"}, {"added_components", {"obstacle_classifier"}}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Install); +} + +TEST(OperationDispatcher, UninstallFromRemovedComponents) { + nlohmann::json m = {{"id", "x"}, {"removed_components", {"broken_lidar_legacy"}}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Uninstall); +} + +TEST(OperationDispatcher, UnknownWhenAllEmpty) { + nlohmann::json m = {{"id", "x"}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Unknown); +} + +TEST(OperationDispatcher, UnknownWhenMixed) { + nlohmann::json m = { + {"id", "x"}, + {"added_components", {"a"}}, + {"removed_components", {"b"}}, + }; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Unknown); +} + +TEST(OperationDispatcher, UnknownWhenComponentsAreEmptyArray) { + nlohmann::json m = {{"id", "x"}, {"updated_components", nlohmann::json::array()}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Unknown); +} + +TEST(OperationDispatcher, UnknownWhenComponentsIsNotArray) { + nlohmann::json m = {{"id", "x"}, {"updated_components", "scan_sensor_node"}}; + EXPECT_EQ(OperationDispatcher::classify(m), OperationKind::Unknown); +} diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp new file mode 100644 index 0000000..09e109c --- /dev/null +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp @@ -0,0 +1,141 @@ +// 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 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include +#include +#include +#include +#include + +#include + +#include + +#include "catalog_client.hpp" +#include "ota_update_plugin/ota_update_plugin.hpp" + +namespace { + +class FakeCatalogClient : public ota_update_plugin::CatalogClient { + public: + using CatalogClient::CatalogClient; + + nlohmann::json catalog_payload = nlohmann::json::array(); + std::string artifact_to_return = "TARDATA"; + std::string requested_url; + + tl::expected fetch_catalog() override { + return catalog_payload; + } + + tl::expected download_artifact(const std::string & url, const std::string & out) override { + requested_url = url; + std::ofstream o(out, std::ios::binary); + o << artifact_to_return; + return out; + } +}; + +ros2_medkit_gateway::UpdateProgressReporter make_reporter(ros2_medkit_gateway::UpdateStatusInfo & info, + std::mutex & mu) { + return ros2_medkit_gateway::UpdateProgressReporter(info, mu); +} + +} // namespace + +TEST(OtaUpdatePluginSmoke, NameAndConstructible) { + ota_update_plugin::OtaUpdatePlugin plugin; + EXPECT_EQ(plugin.name(), "ota_update_plugin"); +} + +TEST(OtaUpdatePluginSmoke, RegisterListGet) { + ota_update_plugin::OtaUpdatePlugin plugin; + nlohmann::json md = {{"id", "u1"}, {"updated_components", {"x"}}}; + ASSERT_TRUE(plugin.register_update(md)); + auto ids = plugin.list_updates({}); + ASSERT_TRUE(ids); + ASSERT_EQ(ids->size(), 1u); + EXPECT_EQ((*ids)[0], "u1"); + auto got = plugin.get_update("u1"); + ASSERT_TRUE(got); + EXPECT_EQ((*got)["id"], "u1"); +} + +TEST(OtaUpdatePluginSmoke, RegisterRequiresId) { + ota_update_plugin::OtaUpdatePlugin plugin; + auto rc = plugin.register_update(nlohmann::json::object()); + EXPECT_FALSE(rc); + EXPECT_EQ(rc.error().code, ros2_medkit_gateway::UpdateBackendError::InvalidInput); +} + +TEST(OtaUpdatePluginSmoke, GetUpdateReturnsNotFoundForUnknownId) { + ota_update_plugin::OtaUpdatePlugin plugin; + auto got = plugin.get_update("does-not-exist"); + ASSERT_FALSE(got); + EXPECT_EQ(got.error().code, ros2_medkit_gateway::UpdateBackendError::NotFound); +} + +TEST(OtaUpdatePluginSmoke, DeleteRemovesEntry) { + ota_update_plugin::OtaUpdatePlugin plugin; + ASSERT_TRUE(plugin.register_update({{"id", "to-delete"}, {"updated_components", {"x"}}})); + ASSERT_TRUE(plugin.delete_update("to-delete")); + auto got = plugin.get_update("to-delete"); + EXPECT_FALSE(got); +} + +TEST(OtaUpdatePluginSmoke, BootPollPopulates) { + ota_update_plugin::OtaUpdatePlugin plugin; + plugin.configure(nlohmann::json::object()); + auto fake = std::make_unique("http://x"); + fake->catalog_payload = nlohmann::json::array({ + {{"id", "a"}, + {"updated_components", {"scan"}}, + {"x_medkit_artifact_url", "/artifacts/a.tgz"}, + {"x_medkit_target_package", "a"}}, + }); + plugin.set_catalog_client_for_test(std::move(fake)); + plugin.poll_and_register_catalog(); + + auto ids = plugin.list_updates({}); + ASSERT_TRUE(ids); + ASSERT_EQ(ids->size(), 1u); + EXPECT_EQ((*ids)[0], "a"); +} + +TEST(OtaUpdatePluginSmoke, PrepareRejectsUnknownOperationKind) { + ota_update_plugin::OtaUpdatePlugin plugin; + ASSERT_TRUE(plugin.register_update({{"id", "bad"}})); + ros2_medkit_gateway::UpdateStatusInfo info; + std::mutex mu; + auto reporter = make_reporter(info, mu); + auto rc = plugin.prepare("bad", reporter); + ASSERT_FALSE(rc); + EXPECT_EQ(rc.error().code, ros2_medkit_gateway::UpdateBackendError::InvalidInput); +} + +TEST(OtaUpdatePluginSmoke, PrepareUninstallSkipsDownload) { + ota_update_plugin::OtaUpdatePlugin plugin; + plugin.configure(nlohmann::json::object()); + // No download should happen for uninstall, but provide a fake just in case. + auto fake = std::make_unique("http://x"); + plugin.set_catalog_client_for_test(std::move(fake)); + ASSERT_TRUE(plugin.register_update({{"id", "rm"}, {"removed_components", {"legacy"}}})); + + ros2_medkit_gateway::UpdateStatusInfo info; + std::mutex mu; + auto reporter = make_reporter(info, mu); + auto rc = plugin.prepare("rm", reporter); + EXPECT_TRUE(rc); + EXPECT_EQ(info.progress.value_or(-1), 100); +} From 78816db4d85dec09423627cb150e66b59aecca7e Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:37:40 +0200 Subject: [PATCH 22/36] fix(ota_plugin): double-fork to avoid zombies, init catalog client in configure, add -Wshadow -Wconversion --- .../ota_update_plugin/CMakeLists.txt | 2 +- .../src/ota_update_plugin.cpp | 9 +++----- .../ota_update_plugin/src/process_runner.cpp | 22 ++++++++++++++----- 3 files changed, 21 insertions(+), 12 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt index 9798bd7..050ca09 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/CMakeLists.txt @@ -16,7 +16,7 @@ cmake_minimum_required(VERSION 3.16) project(ota_update_plugin CXX) if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") - add_compile_options(-Wall -Wextra -Wpedantic) + add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) endif() set(CMAKE_CXX_STANDARD 17) diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp index 6b170c2..23a15aa 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp @@ -76,6 +76,9 @@ void OtaUpdatePlugin::configure(const nlohmann::json & config) { catalog_url_ = config.value("catalog_url", "http://ota_update_server:9000"); staging_dir_ = config.value("staging_dir", "/tmp/ota_staging"); install_dir_ = config.value("install_dir", "/ws/install"); + if (!catalog_client_) { + catalog_client_ = std::make_unique(catalog_url_); + } } void OtaUpdatePlugin::set_context(ros2_medkit_gateway::PluginContext & /*context*/) { @@ -83,9 +86,6 @@ void OtaUpdatePlugin::set_context(ros2_medkit_gateway::PluginContext & /*context } void OtaUpdatePlugin::poll_and_register_catalog() { - if (!catalog_client_) { - catalog_client_ = std::make_unique(catalog_url_); - } auto fetched = catalog_client_->fetch_catalog(); if (!fetched) { std::fprintf(stderr, "[ota_update_plugin] catalog fetch failed: %s\n", fetched.error().c_str()); @@ -184,9 +184,6 @@ tl::expected OtaUpdatePlugin::prepare( const std::string staged_path = staging_dir_ + "/" + id + ".tar.gz"; reporter.set_progress(10); - if (!catalog_client_) { - catalog_client_ = std::make_unique(catalog_url_); - } auto dl = catalog_client_->download_artifact(url, staged_path); if (!dl) { return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "download failed: " + dl.error()}); diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp index 2c6741f..3568d2c 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp @@ -113,17 +113,29 @@ tl::expected ProcessRunner::kill_by_executable(const std::stri } tl::expected ProcessRunner::spawn(const std::string & executable_path) { + // Double-fork so the grandchild is reparented to init and never becomes a + // zombie in the gateway process. The intermediate child exits immediately + // and is reaped here. pid_t pid = fork(); if (pid < 0) { return tl::make_unexpected(std::string("fork failed: ") + std::strerror(errno)); } if (pid == 0) { - // Child: detach from controlling terminal. - setsid(); - execl(executable_path.c_str(), executable_path.c_str(), nullptr); - std::fprintf(stderr, "execl %s failed: %s\n", executable_path.c_str(), std::strerror(errno)); - _exit(127); + pid_t grandchild = fork(); + if (grandchild < 0) { + _exit(126); + } + if (grandchild == 0) { + setsid(); + execl(executable_path.c_str(), executable_path.c_str(), nullptr); + std::fprintf(stderr, "execl %s failed: %s\n", executable_path.c_str(), + std::strerror(errno)); + _exit(127); + } + _exit(0); } + int status = 0; + ::waitpid(pid, &status, 0); return static_cast(pid); } From 3517bb80b42873cf0ed753b382ba6eca5a8849fe Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:52:58 +0200 Subject: [PATCH 23/36] feat(demos/ota): thread x_medkit_replaces_executable for update kind Adds optional --replaces-executable flag to pack_artifact.py and threads it into the catalog entry as x_medkit_replaces_executable when kind=update. This lets the gateway plugin kill the OLD executable (broken_lidar_node) before spawning the NEW one (fixed_lidar_node) when the two live in separate ROS 2 packages. --- .../scripts/build_artifacts.sh | 1 + .../ota_nav2_sensor_fix/scripts/pack_artifact.py | 14 ++++++++++++++ .../scripts/test_pack_artifact.py | 16 ++++++++++++++++ 3 files changed, 31 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh index 6588466..c34ce0e 100755 --- a/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh +++ b/demos/ota_nav2_sensor_fix/scripts/build_artifacts.sh @@ -25,6 +25,7 @@ env -i PATH=/usr/bin:/bin HOME="$HOME" "${PACK[@]}" \ --package fixed_lidar --version 2.1.0 \ --kind update --target-component scan_sensor_node \ --executable fixed_lidar_node \ + --replaces-executable broken_lidar_node \ --notes "Fix /scan noise filter" \ --skip-build --workspace "$WS" \ --out-dir "$ARTIFACTS" --catalog "$ARTIFACTS/catalog.json" diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index 06f3df1..2f80f41 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -39,6 +39,14 @@ def build_parser() -> argparse.ArgumentParser: default="", help="Executable name inside install//lib (required for install).", ) + parser.add_argument( + "--replaces-executable", + default="", + help=( + "For kind=update: name of the OLD executable to kill before " + "spawning --executable. Defaults to --executable when omitted." + ), + ) parser.add_argument("--notes", default="", help="Free-text notes for the catalog entry.") parser.add_argument( "--duration", @@ -83,6 +91,7 @@ def build_entry( notes: str, duration: int, size_bytes: int, + replaces_executable: str = "", ) -> dict: entry: dict = { "id": slug(package, version) if kind != "uninstall" else f"{package}_remove", @@ -112,6 +121,9 @@ def build_entry( else: entry["x_medkit_target_package"] = package + if kind == "update" and replaces_executable: + entry["x_medkit_replaces_executable"] = replaces_executable + return entry @@ -165,6 +177,7 @@ def run( catalog: str, skip_build: bool, workspace: str, + replaces_executable: str = "", ) -> int: if kind == "install" and not executable: sys.stderr.write("--executable is required for install\n") @@ -202,6 +215,7 @@ def run( notes=notes, duration=duration, size_bytes=size_bytes, + replaces_executable=replaces_executable, ) merge_catalog(catalog_p, entry) print(f"packed {entry['id']}") diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index bced302..fd6b0c8 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -70,6 +70,22 @@ def test_build_entry_update_kind(): assert entry["x_medkit_target_package"] == "fixed_lidar" assert entry["x_medkit_executable"] == "fixed_lidar_node" assert entry["x_medkit_artifact_url"] == "/artifacts/fixed_lidar-2.1.0.tar.gz" + assert "x_medkit_replaces_executable" not in entry + + +def test_build_entry_update_kind_with_replaces(): + entry = pack_artifact.build_entry( + package="fixed_lidar", + version="2.1.0", + kind="update", + target_component="scan_sensor_node", + executable="fixed_lidar_node", + replaces_executable="broken_lidar_node", + notes="", + duration=10, + size_bytes=1024, + ) + assert entry["x_medkit_replaces_executable"] == "broken_lidar_node" def test_build_entry_install_kind(): From 3bb6b1bd3a4f511b21fb21978b578dd7423d3ede Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:54:41 +0200 Subject: [PATCH 24/36] fix(ota_plugin): honor x_medkit_replaces_executable when killing old process When a SOVD update package swaps a node across ROS 2 packages (e.g. broken_lidar -> fixed_lidar), the OLD process binary basename differs from the new one. Read x_medkit_replaces_executable from the entry metadata before issuing the kill, falling back to x_medkit_executable when the field is absent (in-package upgrades). --- .../src/ota_update_plugin.cpp | 7 +- .../test/test_plugin_smoke.cpp | 83 +++++++++++++++++++ 2 files changed, 89 insertions(+), 1 deletion(-) diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp index 23a15aa..f081a10 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/ota_update_plugin.cpp @@ -225,8 +225,13 @@ tl::expected OtaUpdatePlugin::execute( return tl::make_unexpected( UpdateBackendErrorInfo{UpdateBackendError::InvalidInput, "missing x_medkit_executable"}); } + // For an update across packages (e.g. broken_lidar -> fixed_lidar) the + // OLD process binary lives in a different package than the NEW one we + // are about to spawn, so its basename differs from `executable`. Honor + // x_medkit_replaces_executable when present, fall back to executable. + const std::string kill_target = metadata.value("x_medkit_replaces_executable", executable); reporter.set_progress(20); - auto kr = process_runner_->kill_by_executable(executable); + auto kr = process_runner_->kill_by_executable(kill_target); if (!kr) { return tl::make_unexpected(UpdateBackendErrorInfo{UpdateBackendError::Internal, "kill failed: " + kr.error()}); } diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp index 09e109c..438bba7 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/test/test_plugin_smoke.cpp @@ -24,6 +24,7 @@ #include "catalog_client.hpp" #include "ota_update_plugin/ota_update_plugin.hpp" +#include "process_runner.hpp" namespace { @@ -47,6 +48,29 @@ class FakeCatalogClient : public ota_update_plugin::CatalogClient { } }; +/// ProcessRunner stub: records the basename passed to kill_by_executable so a +/// test can verify the plugin honors x_medkit_replaces_executable. Returns 0 +/// signalled processes (no-op kill) and an error from spawn so execute() halts +/// before touching the (nonexistent) install dir. +class RecordingProcessRunner : public ota_update_plugin::ProcessRunner { + public: + std::string last_kill_target; + + std::vector pgrep(const std::string & /*executable_basename*/) override { + return {}; + } + + tl::expected kill_by_executable(const std::string & executable_basename, + int /*timeout_ms*/ = 2000) override { + last_kill_target = executable_basename; + return 0; + } + + tl::expected spawn(const std::string & /*executable_path*/) override { + return tl::make_unexpected(std::string("stub: spawn intentionally not implemented")); + } +}; + ros2_medkit_gateway::UpdateProgressReporter make_reporter(ros2_medkit_gateway::UpdateStatusInfo & info, std::mutex & mu) { return ros2_medkit_gateway::UpdateProgressReporter(info, mu); @@ -139,3 +163,62 @@ TEST(OtaUpdatePluginSmoke, PrepareUninstallSkipsDownload) { EXPECT_TRUE(rc); EXPECT_EQ(info.progress.value_or(-1), 100); } + +TEST(OtaUpdatePluginSmoke, ExecuteUpdateUsesReplacesExecutableForKill) { + ota_update_plugin::OtaUpdatePlugin plugin; + plugin.configure({{"staging_dir", ::testing::TempDir() + "/replaces_test"}}); + plugin.set_catalog_client_for_test(std::make_unique("http://x")); + auto runner = std::make_unique(); + RecordingProcessRunner * runner_raw = runner.get(); + plugin.set_process_runner_for_test(std::move(runner)); + + // Update entry with separate old + new executable basenames. + ASSERT_TRUE(plugin.register_update({ + {"id", "u_replaces"}, + {"updated_components", {"scan_sensor_node"}}, + {"x_medkit_artifact_url", "/artifacts/fixed.tgz"}, + {"x_medkit_target_package", "fixed_lidar"}, + {"x_medkit_executable", "fixed_lidar_node"}, + {"x_medkit_replaces_executable", "broken_lidar_node"}, + })); + + ros2_medkit_gateway::UpdateStatusInfo info; + std::mutex mu; + auto reporter = make_reporter(info, mu); + ASSERT_TRUE(plugin.prepare("u_replaces", reporter)); + + // execute() will fail at extract_and_swap (the staged tarball is not a real + // gzipped archive) but the kill step runs first - that is what we are + // checking here. + auto rc = plugin.execute("u_replaces", reporter); + (void)rc; + EXPECT_EQ(runner_raw->last_kill_target, "broken_lidar_node"); +} + +TEST(OtaUpdatePluginSmoke, ExecuteUpdateFallsBackToExecutableWhenReplacesMissing) { + ota_update_plugin::OtaUpdatePlugin plugin; + plugin.configure({{"staging_dir", ::testing::TempDir() + "/replaces_fallback"}}); + plugin.set_catalog_client_for_test(std::make_unique("http://x")); + auto runner = std::make_unique(); + RecordingProcessRunner * runner_raw = runner.get(); + plugin.set_process_runner_for_test(std::move(runner)); + + // Update entry without x_medkit_replaces_executable - kill should target + // the same name as x_medkit_executable. + ASSERT_TRUE(plugin.register_update({ + {"id", "u_no_replaces"}, + {"updated_components", {"scan_sensor_node"}}, + {"x_medkit_artifact_url", "/artifacts/scan.tgz"}, + {"x_medkit_target_package", "scan_pkg"}, + {"x_medkit_executable", "scan_node"}, + })); + + ros2_medkit_gateway::UpdateStatusInfo info; + std::mutex mu; + auto reporter = make_reporter(info, mu); + ASSERT_TRUE(plugin.prepare("u_no_replaces", reporter)); + + auto rc = plugin.execute("u_no_replaces", reporter); + (void)rc; + EXPECT_EQ(runner_raw->last_kill_target, "scan_node"); +} From f088c7eaa1693007a67617704608aafde801e36d Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 18:58:38 +0200 Subject: [PATCH 25/36] feat(demos/ota): docker compose stack + gateway config + entrypoint + README --- demos/ota_nav2_sensor_fix/Dockerfile.gateway | 72 +++++++++++++++ demos/ota_nav2_sensor_fix/README.md | 88 +++++++++++++++++++ demos/ota_nav2_sensor_fix/docker-compose.yml | 37 ++++++++ demos/ota_nav2_sensor_fix/entrypoint.sh | 27 ++++++ demos/ota_nav2_sensor_fix/gateway_config.yaml | 38 ++++++++ 5 files changed, 262 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/Dockerfile.gateway create mode 100644 demos/ota_nav2_sensor_fix/README.md create mode 100644 demos/ota_nav2_sensor_fix/docker-compose.yml create mode 100755 demos/ota_nav2_sensor_fix/entrypoint.sh create mode 100644 demos/ota_nav2_sensor_fix/gateway_config.yaml diff --git a/demos/ota_nav2_sensor_fix/Dockerfile.gateway b/demos/ota_nav2_sensor_fix/Dockerfile.gateway new file mode 100644 index 0000000..01f4b36 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/Dockerfile.gateway @@ -0,0 +1,72 @@ +# 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 --symlink-install \ + --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-sensor-msgs \ + ros-jazzy-visualization-msgs \ + ros-jazzy-launch-ros \ + 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"] diff --git a/demos/ota_nav2_sensor_fix/README.md b/demos/ota_nav2_sensor_fix/README.md new file mode 100644 index 0000000..77e7ecf --- /dev/null +++ b/demos/ota_nav2_sensor_fix/README.md @@ -0,0 +1,88 @@ +# 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 +# 1. Build artifacts (compiles fixed_lidar + obstacle_classifier_v2, +# generates catalog.json + tarballs). +./scripts/build_artifacts.sh + +# 2. Start the stack (gateway + plugin + demo nodes + update server). +docker compose up --build +``` + +In another terminal, drive the demo: + +```bash +# List the registered updates. +curl -s http://localhost:8080/api/v1/updates | jq '.[].id' + +# Run an update: prepare downloads the artifact, execute swaps + restarts. +curl -X PUT http://localhost:8080/api/v1/updates/fixed_lidar_2_1_0/prepare +curl -X PUT http://localhost:8080/api/v1/updates/fixed_lidar_2_1_0/execute + +# Install a new package. +curl -X PUT http://localhost:8080/api/v1/updates/obstacle_classifier_v2_1_0_0/prepare +curl -X PUT http://localhost:8080/api/v1/updates/obstacle_classifier_v2_1_0_0/execute + +# Uninstall a deprecated one. +curl -X PUT http://localhost:8080/api/v1/updates/broken_lidar_legacy_remove/execute +``` + +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. diff --git a/demos/ota_nav2_sensor_fix/docker-compose.yml b/demos/ota_nav2_sensor_fix/docker-compose.yml new file mode 100644 index 0000000..c1bb262 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/docker-compose.yml @@ -0,0 +1,37 @@ +# 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: + - "8080:8080" + environment: + ROS_DOMAIN_ID: 42 + depends_on: + - ota_update_server + + ota_update_server: + image: selfpatch/ota_update_server:dev + build: + context: ./ota_update_server + container_name: ota_demo_update_server + networks: [otanet] + ports: + - "9000:9000" + volumes: + - ./artifacts:/artifacts:ro + +networks: + otanet: + driver: bridge diff --git a/demos/ota_nav2_sensor_fix/entrypoint.sh b/demos/ota_nav2_sensor_fix/entrypoint.sh new file mode 100755 index 0000000..5cf39f1 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/entrypoint.sh @@ -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 diff --git a/demos/ota_nav2_sensor_fix/gateway_config.yaml b/demos/ota_nav2_sensor_fix/gateway_config.yaml new file mode 100644 index 0000000..3cbda32 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/gateway_config.yaml @@ -0,0 +1,38 @@ +# 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. +# +# Gateway configuration for the OTA nav2 sensor-fix demo. +# Enables /updates endpoints and loads ota_update_plugin which polls the +# update server's /catalog at boot and exposes Update / Install / Uninstall +# operations over the SOVD HTTP API. + +ros2_medkit_gateway: + ros__parameters: + server: + host: "0.0.0.0" + port: 8080 + + refresh_interval_ms: 2000 + + # CORS so an external Foxglove panel or browser can hit the API. + cors: + allowed_origins: ["*"] + allowed_methods: ["GET", "PUT", "POST", "DELETE", "OPTIONS"] + allowed_headers: ["Content-Type", "Accept"] + allow_credentials: false + max_age_seconds: 86400 + + discovery: + mode: "runtime_only" + + # Enable /updates endpoints; provider supplied by ota_update_plugin below. + updates: + enabled: true + + plugins: ["ota_update_plugin"] + plugins.ota_update_plugin.path: "/ws/install/ota_update_plugin/lib/ota_update_plugin/ota_update_plugin.so" + plugins.ota_update_plugin.catalog_url: "http://ota_update_server:9000" + plugins.ota_update_plugin.staging_dir: "/tmp/ota_staging" + plugins.ota_update_plugin.install_dir: "/ws/install" From 2f7d8176c6c8f3ddf5ccca3c1c7506c9dcf9e8dd Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 19:09:09 +0200 Subject: [PATCH 26/36] fix(ota_plugin): __has_include compat for older gateway updates/ header path --- .../include/ota_update_plugin/ota_update_plugin.hpp | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp index adb1b49..7e67c0b 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/include/ota_update_plugin/ota_update_plugin.hpp @@ -22,7 +22,12 @@ #include #include +// UpdateProvider lives at providers/ in newer gateway revisions and updates/ in older ones. +#if __has_include() #include +#else +#include +#endif namespace ota_update_plugin { From ec5070faa3e9ef5c357b0726e7445ecf5e103db8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 19:58:25 +0200 Subject: [PATCH 27/36] fix(ota_plugin): cmdline-based pgrep + UpdateProvider C export + runtime libs in image - ProcessRunner::pgrep now reads /proc//cmdline argv[0] basename instead of /proc//comm (which kernel truncates to 15 chars - 'broken_lidar_node' would never match). - plugin_exports.cpp exports get_update_provider so the gateway's plugin_loader can resolve the UpdateProvider interface across the dlopen boundary without relying on dynamic_cast. - Dockerfile.gateway: drop --symlink-install (broke multi-stage COPY) and add runtime libs (libcpp-httplib, libsystemd, nlohmann-json3, lifecycle, test_msgs). - ota_update_server Dockerfile: bake artifacts/ into image (WSL2 + Docker Desktop bind mounts unreliable). - Compose: gateway port configurable via OTA_GATEWAY_PORT (default 8080). Verified via end-to-end smoke against the live stack: - Plugin loads and reports as UpdateProvider - Boot poll registers all 3 catalog entries - Update flow kills broken_lidar_node and spawns fixed_lidar_node --- demos/ota_nav2_sensor_fix/Dockerfile.gateway | 7 +++++- demos/ota_nav2_sensor_fix/docker-compose.yml | 7 +++--- .../ota_update_plugin/src/plugin_exports.cpp | 8 +++++++ .../ota_update_plugin/src/process_runner.cpp | 23 ++++++++++++++----- .../ota_update_server/Dockerfile | 12 ++++++++-- 5 files changed, 44 insertions(+), 13 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/Dockerfile.gateway b/demos/ota_nav2_sensor_fix/Dockerfile.gateway index 01f4b36..e355e74 100644 --- a/demos/ota_nav2_sensor_fix/Dockerfile.gateway +++ b/demos/ota_nav2_sensor_fix/Dockerfile.gateway @@ -45,7 +45,7 @@ WORKDIR /ws RUN apt-get update RUN . /opt/ros/jazzy/setup.sh && \ rosdep install --from-paths src --ignore-src -r -y --rosdistro=jazzy && \ - colcon build --symlink-install \ + colcon build \ --cmake-args -DCMAKE_BUILD_TYPE=Release && \ rm -rf /var/lib/apt/lists/* @@ -54,9 +54,14 @@ 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/* diff --git a/demos/ota_nav2_sensor_fix/docker-compose.yml b/demos/ota_nav2_sensor_fix/docker-compose.yml index c1bb262..52c8644 100644 --- a/demos/ota_nav2_sensor_fix/docker-compose.yml +++ b/demos/ota_nav2_sensor_fix/docker-compose.yml @@ -15,7 +15,7 @@ services: container_name: ota_demo_gateway networks: [otanet] ports: - - "8080:8080" + - "${OTA_GATEWAY_PORT:-8080}:8080" environment: ROS_DOMAIN_ID: 42 depends_on: @@ -24,13 +24,12 @@ services: ota_update_server: image: selfpatch/ota_update_server:dev build: - context: ./ota_update_server + context: . + dockerfile: ota_update_server/Dockerfile container_name: ota_demo_update_server networks: [otanet] ports: - "9000:9000" - volumes: - - ./artifacts:/artifacts:ro networks: otanet: diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp index a5916e0..3a8c104 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/plugin_exports.cpp @@ -23,3 +23,11 @@ extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { extern "C" GATEWAY_PLUGIN_EXPORT ros2_medkit_gateway::GatewayPlugin * create_plugin() { return new ota_update_plugin::OtaUpdatePlugin(); } + +// Explicit cross-cast so the gateway's plugin_loader can resolve the +// UpdateProvider interface without relying on dynamic_cast across the +// dlopen boundary (which is fragile when typeinfo isn't shared). +extern "C" GATEWAY_PLUGIN_EXPORT ros2_medkit_gateway::UpdateProvider * +get_update_provider(ros2_medkit_gateway::GatewayPlugin * plugin) { + return dynamic_cast(plugin); +} diff --git a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp index 3568d2c..8ec65e9 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp +++ b/demos/ota_nav2_sensor_fix/ota_update_plugin/src/process_runner.cpp @@ -32,14 +32,25 @@ namespace ota_update_plugin { namespace { -std::string proc_comm(int pid) { - std::ifstream f("/proc/" + std::to_string(pid) + "/comm"); +// /proc//comm is truncated to 15 characters by the kernel, which causes +// false negatives for any executable whose basename is longer (e.g. +// "broken_lidar_node" -> "broken_lidar_no"). Read /proc//cmdline +// instead - its first NUL-separated arg holds the full path / argv[0]. +std::string proc_cmdline_arg0(int pid) { + std::ifstream f("/proc/" + std::to_string(pid) + "/cmdline", std::ios::binary); if (!f) { return {}; } - std::string line; - std::getline(f, line); - return line; + std::string buf((std::istreambuf_iterator(f)), std::istreambuf_iterator()); + if (buf.empty()) { + return {}; + } + // argv[0] runs to the first NUL. + const auto nul = buf.find('\0'); + std::string arg0 = (nul == std::string::npos) ? buf : buf.substr(0, nul); + // Take the basename so callers pass executable_basename without a path. + const auto slash = arg0.rfind('/'); + return (slash == std::string::npos) ? arg0 : arg0.substr(slash + 1); } bool is_pid_dir(const char * name) { @@ -67,7 +78,7 @@ std::vector ProcessRunner::pgrep(const std::string & executable_basename) { if (pid <= 0) { continue; } - if (proc_comm(pid) == executable_basename) { + if (proc_cmdline_arg0(pid) == executable_basename) { out.push_back(pid); } } diff --git a/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile b/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile index e5a59af..7bc0599 100644 --- a/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile +++ b/demos/ota_nav2_sensor_fix/ota_update_server/Dockerfile @@ -1,10 +1,18 @@ FROM python:3.11-slim +# Build context expected to be the demo root (so we can pull in artifacts/) +# rather than ota_update_server/. Compose wires this up. + WORKDIR /app -COPY pyproject.toml ./ -COPY ota_update_server ./ota_update_server +COPY ota_update_server/pyproject.toml ./ +COPY ota_update_server/ota_update_server ./ota_update_server RUN pip install --no-cache-dir . +# Bake the demo catalog + tarballs into the image so the container is +# self-contained. Bind-mounting artifacts/ at runtime is unreliable on +# WSL2 + Docker Desktop, so we ship them in the image instead. +COPY artifacts /artifacts + ENV OTA_ARTIFACTS_DIR=/artifacts ENV OTA_HOST=0.0.0.0 ENV OTA_PORT=9000 From 80e4af11ef1a09463fbde7edd459afd1a560206c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 21:29:46 +0200 Subject: [PATCH 28/36] fix(demos/ota): use SOVD spec field names update_name + x_medkit_version pack_artifact.py was emitting 'name' (not in SOVD ISO 17978-3 - spec uses 'update_name') and 'version' (not a SOVD field at all). Spec-compliant clients (ros2_medkit_web_ui, the Foxglove updates panel) expect update_name; vendor-specific data lives under x_medkit_*. Confirmed against the live demo gateway: the web UI happily renders the updated shape, all 3 catalog entries visible end-to-end. --- demos/ota_nav2_sensor_fix/scripts/pack_artifact.py | 10 ++++++++-- .../ota_nav2_sensor_fix/scripts/test_pack_artifact.py | 6 ++++-- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py index 2f80f41..fabe8e4 100644 --- a/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/pack_artifact.py @@ -95,14 +95,20 @@ def build_entry( ) -> dict: entry: dict = { "id": slug(package, version) if kind != "uninstall" else f"{package}_remove", - "name": f"{package} {version}".strip(), + # SOVD ISO 17978-3 mandates "update_name". Earlier drafts of this + # script wrote "name" - the gateway passes that through to clients + # but spec-compliant consumers (web UI, Foxglove panel) expect + # update_name. + "update_name": f"{package} {version}".strip(), "automated": False, "origins": ["remote"], "notes": notes, "duration": duration, } if version: - entry["version"] = version + # SOVD spec does not define a top-level version field on update + # detail, so we expose it as a vendor extension. + entry["x_medkit_version"] = version if size_bytes > 0: entry["size"] = max(1, size_bytes // 1024) diff --git a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py index fd6b0c8..36d840c 100644 --- a/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py +++ b/demos/ota_nav2_sensor_fix/scripts/test_pack_artifact.py @@ -57,8 +57,10 @@ def test_build_entry_update_kind(): size_bytes=2048, ) assert entry["id"] == "fixed_lidar_2_1_0" - assert entry["name"] == "fixed_lidar 2.1.0" - assert entry["version"] == "2.1.0" + assert entry["update_name"] == "fixed_lidar 2.1.0" + assert "name" not in entry, "use update_name (SOVD spec) not name" + assert entry["x_medkit_version"] == "2.1.0" + assert "version" not in entry, "version is not a SOVD field; use x_medkit_version" assert entry["automated"] is False assert entry["origins"] == ["remote"] assert entry["notes"] == "fix noise" From 8f48af129507f537f30b4952654f928da230ff59 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Sun, 26 Apr 2026 21:33:08 +0200 Subject: [PATCH 29/36] test(demos/ota): committable Playwright e2e smoke driving web UI against gateway Verifies the canonical SOVD client flow that the Foxglove updates panel mirrors: connect form, /api/v1/updates returns {items: []}, per-id /status calls, all 3 catalog entries render in the dashboard. --- .../scripts/e2e_webui_smoke.mjs | 97 +++++++++++++++++++ 1 file changed, 97 insertions(+) create mode 100644 demos/ota_nav2_sensor_fix/scripts/e2e_webui_smoke.mjs diff --git a/demos/ota_nav2_sensor_fix/scripts/e2e_webui_smoke.mjs b/demos/ota_nav2_sensor_fix/scripts/e2e_webui_smoke.mjs new file mode 100644 index 0000000..1937ffc --- /dev/null +++ b/demos/ota_nav2_sensor_fix/scripts/e2e_webui_smoke.mjs @@ -0,0 +1,97 @@ +// E2E smoke driver for the OTA demo. Drives ros2_medkit_web_ui (running +// at WEB_UI_URL) against the live demo gateway (GATEWAY_URL) and asserts +// that all 3 catalog entries register and that the SOVD wire format +// matches what we ship from pack_artifact.py. +// +// Why this exists: the Foxglove updates panel mirrors the same SOVD client +// patterns the web UI uses (fetchUpdateIds parses {items: [...]}, +// per-id /status, lazy /detail). Verifying the web UI flow end-to-end +// gives us a canonical reference point for both clients. +// +// Usage: +// docker compose up -d +// cd /path/to/ros2_medkit_web_ui && npm install && npm run dev +// GATEWAY_URL=http://localhost:8080 \ +// WEB_UI_URL=http://localhost:5173 \ +// node /path/to/this/e2e_webui_smoke.mjs +// +// Requires: playwright (`npm install --no-save playwright` in the web UI +// dir), chromium-headless-shell (`npx playwright install +// chromium-headless-shell`), the demo stack from ../docker-compose.yml. + +import { chromium } from "playwright"; + +const WEB_UI_URL = process.env.WEB_UI_URL ?? "http://localhost:5173/"; +const GATEWAY_URL = process.env.GATEWAY_URL ?? "http://localhost:8080"; + +const EXPECTED_IDS = [ + "fixed_lidar_2_1_0", + "obstacle_classifier_v2_1_0_0", + "broken_lidar_legacy_remove", +]; + +const EXPECTED_API_PATHS = [ + "/api/v1/updates", + "/api/v1/updates/fixed_lidar_2_1_0/status", + "/api/v1/updates/obstacle_classifier_v2_1_0_0/status", + "/api/v1/updates/broken_lidar_legacy_remove/status", +]; + +(async () => { + const browser = await chromium.launch({ + channel: "chromium-headless-shell", + headless: true, + }); + const ctx = await browser.newContext(); + const page = await ctx.newPage(); + + page.on("pageerror", (err) => console.log(`[pageerror] ${err.message}`)); + + const apiCalls = new Set(); + page.on("request", (req) => { + const u = req.url(); + if (u.includes("/api/v1/")) { + apiCalls.add(`${req.method()} ${u}`); + } + }); + + await page.goto(WEB_UI_URL, { waitUntil: "domcontentloaded" }); + + await page.getByRole("button", { name: /connect to server/i }).click(); + await page.waitForTimeout(300); + await page.locator('input[type="text"], input:not([type])').first().fill(GATEWAY_URL); + await page.getByRole("button", { name: /^connect$/i }).last().click(); + await page.waitForTimeout(2000); + + const updatesButton = page.getByRole("button", { name: /updates/i }).first(); + if (await updatesButton.count()) { + await updatesButton.click(); + await page.waitForTimeout(2000); + } + + const bodyText = await page.locator("body").textContent(); + + let failed = 0; + for (const id of EXPECTED_IDS) { + const visible = bodyText?.includes(id) ?? false; + console.log(` id ${id}: ${visible ? "PASS" : "FAIL"}`); + if (!visible) failed++; + } + + for (const path of EXPECTED_API_PATHS) { + const hit = [...apiCalls].some((c) => c.endsWith(path)); + console.log(` api ${path}: ${hit ? "PASS" : "FAIL"}`); + if (!hit) failed++; + } + + await browser.close(); + + if (failed > 0) { + console.error(`\n${failed} assertion(s) failed`); + process.exit(1); + } + console.log("\nDONE: all SOVD flows verified"); +})().catch((err) => { + console.error("FAIL:", err); + process.exit(1); +}); From a1726c401b97236093aa7c24ce73f3ff5f72dca9 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 09:52:27 +0200 Subject: [PATCH 30/36] feat(demos/ota): run-demo / stop-demo / check-demo / trigger-* scripts Adopt the same script convention as sensor_diagnostics, multi_ecu_aggregation, and turtlebot3_integration: ./run-demo.sh build artifacts + bring up gateway + nodes + update server (daemon mode by default, --attached for fg) ./stop-demo.sh tear down (-v removes volumes, --images removes built images) ./check-demo.sh show registered updates + per-id status + live plugin-managed processes inside the gateway container ./trigger-update.sh broken_lidar -> fixed_lidar (the headline) ./trigger-install.sh install obstacle_classifier_v2 from scratch ./trigger-uninstall.sh remove broken_lidar_legacy OTA_GATEWAY_PORT (or OTA_GATEWAY_URL for full override) lets the user sidestep collisions with another gateway on host port 8080. README quickstart updated to point at run-demo.sh. --- demos/ota_nav2_sensor_fix/README.md | 32 ++-- demos/ota_nav2_sensor_fix/check-demo.sh | 56 +++++++ demos/ota_nav2_sensor_fix/run-demo.sh | 147 ++++++++++++++++++ demos/ota_nav2_sensor_fix/stop-demo.sh | 40 +++++ demos/ota_nav2_sensor_fix/trigger-install.sh | 35 +++++ .../ota_nav2_sensor_fix/trigger-uninstall.sh | 37 +++++ demos/ota_nav2_sensor_fix/trigger-update.sh | 36 +++++ 7 files changed, 365 insertions(+), 18 deletions(-) create mode 100755 demos/ota_nav2_sensor_fix/check-demo.sh create mode 100755 demos/ota_nav2_sensor_fix/run-demo.sh create mode 100755 demos/ota_nav2_sensor_fix/stop-demo.sh create mode 100755 demos/ota_nav2_sensor_fix/trigger-install.sh create mode 100755 demos/ota_nav2_sensor_fix/trigger-uninstall.sh create mode 100755 demos/ota_nav2_sensor_fix/trigger-update.sh diff --git a/demos/ota_nav2_sensor_fix/README.md b/demos/ota_nav2_sensor_fix/README.md index 77e7ecf..940a800 100644 --- a/demos/ota_nav2_sensor_fix/README.md +++ b/demos/ota_nav2_sensor_fix/README.md @@ -22,31 +22,27 @@ update package metadata. ## Quickstart ```bash -# 1. Build artifacts (compiles fixed_lidar + obstacle_classifier_v2, -# generates catalog.json + tarballs). -./scripts/build_artifacts.sh - -# 2. Start the stack (gateway + plugin + demo nodes + update server). -docker compose up --build +# 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 -# List the registered updates. -curl -s http://localhost:8080/api/v1/updates | jq '.[].id' - -# Run an update: prepare downloads the artifact, execute swaps + restarts. -curl -X PUT http://localhost:8080/api/v1/updates/fixed_lidar_2_1_0/prepare -curl -X PUT http://localhost:8080/api/v1/updates/fixed_lidar_2_1_0/execute +./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 +``` -# Install a new package. -curl -X PUT http://localhost:8080/api/v1/updates/obstacle_classifier_v2_1_0_0/prepare -curl -X PUT http://localhost:8080/api/v1/updates/obstacle_classifier_v2_1_0_0/execute +Each trigger script issues SOVD `PUT /updates/{id}/prepare` then `/execute` +and prints the resulting status plus the live process list. -# Uninstall a deprecated one. -curl -X PUT http://localhost:8080/api/v1/updates/broken_lidar_legacy_remove/execute -``` +If host port 8080 is taken, override with `OTA_GATEWAY_PORT=8081 ./run-demo.sh`. Tear down: `docker compose down`. diff --git a/demos/ota_nav2_sensor_fix/check-demo.sh b/demos/ota_nav2_sensor_fix/check-demo.sh new file mode 100755 index 0000000..40e49fa --- /dev/null +++ b/demos/ota_nav2_sensor_fix/check-demo.sh @@ -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":""}') + 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 diff --git a/demos/ota_nav2_sensor_fix/run-demo.sh b/demos/ota_nav2_sensor_fix/run-demo.sh new file mode 100755 index 0000000..1989ab9 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/run-demo.sh @@ -0,0 +1,147 @@ +#!/bin/bash +# OTA over SOVD - nav2 sensor-fix demo runner. +# Brings up the gateway (with the dev-grade ota_update_plugin baked in) and +# the FastAPI artifact server. The gateway entrypoint also launches +# broken_lidar (publishes /scan with a phantom obstacle) and +# broken_lidar_legacy (uninstall target). + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +DETACH_MODE="true" +UPDATE_IMAGES="false" +BUILD_ARGS="" +SKIP_ARTIFACTS="false" + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " --attached Run in foreground (default: daemon mode)" + echo " --update Pull latest images before running" + echo " --no-cache Build Docker images without cache" + echo " --skip-artifacts Skip rebuilding artifacts/catalog.json" + echo " -h, --help Show this help message" + echo "" + echo "Environment:" + echo " OTA_GATEWAY_PORT Host port for gateway HTTP API (default: 8080)" + echo "" + echo "Examples:" + echo " $0 # Daemon mode (default)" + echo " $0 --attached # Foreground with logs" + echo " OTA_GATEWAY_PORT=8081 $0 # Use a different host port" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --attached) DETACH_MODE="false" ;; + --update) UPDATE_IMAGES="true" ;; + --no-cache) BUILD_ARGS="--no-cache" ;; + --skip-artifacts) SKIP_ARTIFACTS="true" ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1"; usage; exit 1 ;; + esac + shift +done + +GATEWAY_PORT="${OTA_GATEWAY_PORT:-8080}" +GATEWAY_URL="http://localhost:${GATEWAY_PORT}" + +echo "OTA over SOVD - nav2 sensor-fix demo" +echo "====================================" +echo "" + +if ! command -v docker &> /dev/null; then + echo "Error: Docker is not installed" + exit 1 +fi + +if [[ "$SKIP_ARTIFACTS" != "true" ]]; then + if [[ ! -x "$SCRIPT_DIR/scripts/build_artifacts.sh" ]]; then + chmod +x "$SCRIPT_DIR/scripts/build_artifacts.sh" + fi + echo "[1/3] Building OTA artifacts (catalog.json + tarballs)..." + "$SCRIPT_DIR/scripts/build_artifacts.sh" + echo "" +fi + +if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +if [[ "$UPDATE_IMAGES" == "true" ]]; then + echo "Pulling latest images..." + ${COMPOSE_CMD} pull +fi + +echo "[2/3] Building and starting demo..." +echo " (First run pulls ros:jazzy and builds the gateway, ~10 minutes)" +echo "" + +DETACH_FLAG="" +if [[ "$DETACH_MODE" == "true" ]]; then + DETACH_FLAG="-d" +fi + +# shellcheck disable=SC2086 +if ! ${COMPOSE_CMD} build ${BUILD_ARGS}; then + echo "Docker build failed. Stopping any partially created containers..." + ${COMPOSE_CMD} down 2>/dev/null || true + exit 1 +fi + +# shellcheck disable=SC2086 +${COMPOSE_CMD} up ${DETACH_FLAG} + +if [[ "$DETACH_MODE" != "true" ]]; then + exit 0 +fi + +echo "" +echo "[3/3] Waiting for gateway to come up..." +for i in 1 2 3 4 5 6 7 8 9 10 11 12; do + if curl -fsS "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1; then + break + fi + sleep 2 +done + +if ! curl -fsS "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1; then + echo "Gateway did not respond on ${GATEWAY_URL} - check logs with:" + echo " ${COMPOSE_CMD} logs gateway" + exit 1 +fi + +echo "" +echo "Demo is up." +echo "" +echo " Gateway HTTP API: ${GATEWAY_URL}/api/v1/" +echo " Update server: http://localhost:9000/catalog" +echo "" +echo "Registered updates:" +if command -v jq >/dev/null 2>&1; then + curl -fsS "${GATEWAY_URL}/api/v1/updates" | jq -r '.items[]' | sed 's/^/ /' +else + curl -fsS "${GATEWAY_URL}/api/v1/updates" +fi +echo "" +echo "Drive the demo:" +echo " ./check-demo.sh # show current state" +echo " ./trigger-update.sh # update broken_lidar -> fixed_lidar" +echo " ./trigger-install.sh # install obstacle_classifier_v2" +echo " ./trigger-uninstall.sh # uninstall broken_lidar_legacy" +echo " ./stop-demo.sh # tear down" +echo "" +echo "Connect a UI:" +echo " Web UI (ros2_medkit_web_ui):" +echo " npm install && npm run dev" +echo " open http://localhost:5173 -> Connect -> ${GATEWAY_URL}" +echo "" +echo " Foxglove Studio (ros2_medkit_foxglove_extension):" +echo " cd ros2_medkit_foxglove_extension && npm install && npm run local-install" +echo " Open Foxglove -> add panel 'ros2_medkit Updates'" +echo " Set baseUrl in panel settings to ${GATEWAY_URL}/api/v1" diff --git a/demos/ota_nav2_sensor_fix/stop-demo.sh b/demos/ota_nav2_sensor_fix/stop-demo.sh new file mode 100755 index 0000000..1233d63 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/stop-demo.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Stop the OTA over SOVD - nav2 sensor-fix demo. + +set -eu + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +REMOVE_VOLUMES="" +REMOVE_IMAGES="" + +usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "Options:" + echo " -v, --volumes Remove named volumes" + echo " --images Remove built images" + echo " -h, --help Show this help message" +} + +while [[ $# -gt 0 ]]; do + case "$1" in + -v|--volumes) REMOVE_VOLUMES="-v" ;; + --images) REMOVE_IMAGES="--rmi local" ;; + -h|--help) usage; exit 0 ;; + *) echo "Unknown option: $1"; usage; exit 1 ;; + esac + shift +done + +if docker compose version &> /dev/null; then + COMPOSE_CMD="docker compose" +else + COMPOSE_CMD="docker-compose" +fi + +# shellcheck disable=SC2086 +${COMPOSE_CMD} down ${REMOVE_VOLUMES} ${REMOVE_IMAGES} +echo "" +echo "Demo stopped." diff --git a/demos/ota_nav2_sensor_fix/trigger-install.sh b/demos/ota_nav2_sensor_fix/trigger-install.sh new file mode 100755 index 0000000..d4409b0 --- /dev/null +++ b/demos/ota_nav2_sensor_fix/trigger-install.sh @@ -0,0 +1,35 @@ +#!/bin/bash +# Trigger the SOVD install flow: deploy obstacle_classifier_v2 from scratch. + +set -eu + +GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}" +API="${GATEWAY_URL}/api/v1" +ID="obstacle_classifier_v2_1_0_0" + +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 + +echo "Install: ${ID}" +echo " PUT /updates/${ID}/prepare" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/prepare" >/dev/null +sleep 3 + +echo " PUT /updates/${ID}/execute" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/execute" >/dev/null +sleep 5 + +echo "" +echo "Status after execute:" +curl -fsS "${API}/updates/${ID}/status" | (jq . 2>/dev/null || cat) + +if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then + echo "" + echo "Live processes:" + docker exec ota_demo_gateway pgrep -af 'obstacle_classifier' \ + 2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || echo " (none)" +fi diff --git a/demos/ota_nav2_sensor_fix/trigger-uninstall.sh b/demos/ota_nav2_sensor_fix/trigger-uninstall.sh new file mode 100755 index 0000000..0f7c8fa --- /dev/null +++ b/demos/ota_nav2_sensor_fix/trigger-uninstall.sh @@ -0,0 +1,37 @@ +#!/bin/bash +# Trigger the SOVD uninstall flow: remove broken_lidar_legacy. + +set -eu + +GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}" +API="${GATEWAY_URL}/api/v1" +ID="broken_lidar_legacy_remove" + +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 + +echo "Uninstall: ${ID}" +# Uninstall has no artifact to fetch but the gateway state machine still +# needs prepare->execute to advance. +echo " PUT /updates/${ID}/prepare" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/prepare" >/dev/null +sleep 2 + +echo " PUT /updates/${ID}/execute" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/execute" >/dev/null +sleep 4 + +echo "" +echo "Status after execute:" +curl -fsS "${API}/updates/${ID}/status" | (jq . 2>/dev/null || cat) + +if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then + echo "" + echo "Live processes (broken_lidar_legacy should be gone):" + docker exec ota_demo_gateway pgrep -af 'broken_lidar_legacy' \ + 2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || echo " (none - uninstall succeeded)" +fi diff --git a/demos/ota_nav2_sensor_fix/trigger-update.sh b/demos/ota_nav2_sensor_fix/trigger-update.sh new file mode 100755 index 0000000..5f9668a --- /dev/null +++ b/demos/ota_nav2_sensor_fix/trigger-update.sh @@ -0,0 +1,36 @@ +#!/bin/bash +# Trigger the SOVD update flow: replace broken_lidar with fixed_lidar. +# Uses spec endpoints PUT /updates/{id}/prepare then PUT /updates/{id}/execute. + +set -eu + +GATEWAY_URL="${OTA_GATEWAY_URL:-http://localhost:${OTA_GATEWAY_PORT:-8080}}" +API="${GATEWAY_URL}/api/v1" +ID="fixed_lidar_2_1_0" + +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 + +echo "Update: ${ID}" +echo " PUT /updates/${ID}/prepare" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/prepare" >/dev/null +sleep 3 + +echo " PUT /updates/${ID}/execute" +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API}/updates/${ID}/execute" >/dev/null +sleep 5 + +echo "" +echo "Status after execute:" +curl -fsS "${API}/updates/${ID}/status" | (jq . 2>/dev/null || cat) + +if docker ps --format '{{.Names}}' | grep -q '^ota_demo_gateway$'; then + echo "" + echo "Live processes:" + docker exec ota_demo_gateway pgrep -af 'broken_lidar_node|fixed_lidar_node' \ + 2>/dev/null | grep -v 'pgrep' | sed 's/^/ /' || true +fi From bd7a54f8f8dad187ebd1fe07eddc0d60bf1e7d64 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 10:28:43 +0200 Subject: [PATCH 31/36] test(demos/ota): smoke test + CI job mirroring sensor_diagnostics pattern tests/smoke_test_ota.sh asserts: - gateway /health 200 - gateway log says 'Update backend provided by plugin' (no 'no provider' warn) - GET /updates returns SOVD {items: []} envelope with all 3 catalog ids - GET /updates/{id} detail uses spec field names: update_name (not 'name'), x_medkit_version (not bare 'version'), updated_components for kind, x_medkit_replaces_executable threaded through pack_artifact - update flow: PUT prepare + execute kills broken_lidar_node and spawns fixed_lidar_node inside the gateway container - install flow: spawns obstacle_classifier_node ci.yml gets a build-and-test-ota job following the same shape as the other per-demo jobs: checkout -> install Python + ROS Jazzy on the runner -> build_artifacts.sh -> docker compose up -d --build -> run smoke -> log dumps on failure -> teardown. --- .github/workflows/ci.yml | 68 ++++++++++++++ tests/smoke_test_ota.sh | 190 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 258 insertions(+) create mode 100755 tests/smoke_test_ota.sh diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 588666e..0a009f7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -163,3 +163,71 @@ 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: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install pack_artifact dev deps + working-directory: demos/ota_nav2_sensor_fix/scripts + run: | + python -m venv .venv + .venv/bin/pip install --upgrade pip + .venv/bin/pip install pytest + + - name: Set up ROS 2 Jazzy (host) for build_artifacts.sh + run: | + sudo apt-get update + sudo apt-get install -y software-properties-common curl + sudo add-apt-repository universe -y + sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg + echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list >/dev/null + sudo apt-get update + sudo apt-get install -y ros-jazzy-ros-base python3-colcon-common-extensions + + - name: Build artifacts (catalog + tarballs) + working-directory: demos/ota_nav2_sensor_fix + run: ./scripts/build_artifacts.sh + + - 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 diff --git a/tests/smoke_test_ota.sh b/tests/smoke_test_ota.sh new file mode 100755 index 0000000..1272c94 --- /dev/null +++ b/tests/smoke_test_ota.sh @@ -0,0 +1,190 @@ +#!/bin/bash +# Smoke tests for the ota_nav2_sensor_fix demo. +# Runs from the host against the gateway on localhost:8080 and asserts: +# - the gateway loads our ota_update_plugin as the UpdateProvider +# - the SOVD catalog is registered with the 3 expected entries +# - the update detail uses spec field names (update_name, no `name`/`version`) +# - the update flow actually swaps broken_lidar_node for fixed_lidar_node +# inside the gateway container +# +# Usage: ./tests/smoke_test_ota.sh [GATEWAY_URL] +# Default GATEWAY_URL: http://localhost:8080 + +GATEWAY_URL="${1:-http://localhost:8080}" +# shellcheck disable=SC2034 # Used by smoke_lib.sh +API_BASE="${GATEWAY_URL}/api/v1" + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +# shellcheck source=tests/smoke_lib.sh +source "${SCRIPT_DIR}/smoke_lib.sh" + +trap print_summary EXIT + +GATEWAY_CONTAINER="${OTA_DEMO_GATEWAY_CONTAINER:-ota_demo_gateway}" + +EXPECTED_IDS=( + "fixed_lidar_2_1_0" + "obstacle_classifier_v2_1_0_0" + "broken_lidar_legacy_remove" +) + +# Confirm a process is or is not running inside the gateway container. +# Usage: assert_process_running +# assert_process_gone +assert_process_running() { + local pattern="$1" + local desc="$2" + if docker exec "$GATEWAY_CONTAINER" pgrep -f "$pattern" >/dev/null 2>&1; then + pass "$desc" + else + fail "$desc" "no process matching '$pattern' in $GATEWAY_CONTAINER" + fi +} + +assert_process_gone() { + local pattern="$1" + local desc="$2" + if ! docker exec "$GATEWAY_CONTAINER" pgrep -f "$pattern" >/dev/null 2>&1; then + pass "$desc" + else + fail "$desc" "process matching '$pattern' still alive in $GATEWAY_CONTAINER" + fi +} + +# --- Wait for gateway startup --- + +wait_for_gateway 90 + +# Plugin's boot poll fetches /catalog and registers entries; wait for it. +echo " Waiting for plugin's boot poll to register catalog (max 30s)..." +if poll_until "/updates" '.items[] | select(. == "fixed_lidar_2_1_0")' 30; then + echo " Catalog registered" +else + echo " Catalog NOT registered within 30s" + exit 1 +fi + +# --- Tests --- + +section "Health" + +if api_get "/health"; then + pass "GET /health returns 200" +else + fail "GET /health returns 200" "unexpected status code" +fi + +section "UpdateProvider plugin loaded" + +if docker logs "$GATEWAY_CONTAINER" 2>&1 | grep -q "Update backend provided by plugin"; then + pass "gateway log says: 'Update backend provided by plugin'" +else + fail "gateway log says: 'Update backend provided by plugin'" "log line missing" +fi + +if docker logs "$GATEWAY_CONTAINER" 2>&1 | grep -q "Updates enabled but no UpdateProvider plugin loaded"; then + fail "no 'no UpdateProvider' warning" "warning was logged" +else + pass "no 'no UpdateProvider' warning" +fi + +section "Catalog (GET /updates returns SOVD {items})" + +if api_get "/updates"; then + pass "GET /updates returns 200" +else + fail "GET /updates returns 200" "unexpected status code" +fi + +if echo "$RESPONSE" | jq -e '.items | type == "array"' >/dev/null 2>&1; then + pass "/updates response has items array" +else + fail "/updates response has items array" "envelope mismatch (SOVD spec violation)" +fi + +for id in "${EXPECTED_IDS[@]}"; do + if echo "$RESPONSE" | jq -e --arg id "$id" '.items[] | select(. == $id)' >/dev/null 2>&1; then + pass "/updates contains '$id'" + else + fail "/updates contains '$id'" "id missing" + fi +done + +section "Detail field shape (SOVD ISO 17978-3 compliance)" + +# fixed_lidar update detail: must use spec field names +if api_get "/updates/fixed_lidar_2_1_0"; then + pass "GET /updates/fixed_lidar_2_1_0 returns 200" + + if echo "$RESPONSE" | jq -e '.update_name' >/dev/null 2>&1; then + pass "detail has update_name (SOVD spec)" + else + fail "detail has update_name (SOVD spec)" "field missing - spec violation" + fi + + if echo "$RESPONSE" | jq -e '.name' >/dev/null 2>&1; then + fail "detail does NOT have 'name'" "found 'name' instead of 'update_name'" + else + pass "detail does NOT have 'name'" + fi + + if echo "$RESPONSE" | jq -e '.version' >/dev/null 2>&1; then + fail "detail does NOT have plain 'version'" "should be x_medkit_version (vendor extension)" + else + pass "detail does NOT have plain 'version'" + fi + + if echo "$RESPONSE" | jq -e '.x_medkit_version == "2.1.0"' >/dev/null 2>&1; then + pass "detail has x_medkit_version = 2.1.0" + else + fail "detail has x_medkit_version = 2.1.0" "field missing or wrong value" + fi + + if echo "$RESPONSE" | jq -e '.updated_components | index("scan_sensor_node")' >/dev/null 2>&1; then + pass "detail has updated_components: ['scan_sensor_node']" + else + fail "detail has updated_components: ['scan_sensor_node']" "kind metadata missing" + fi + + if echo "$RESPONSE" | jq -e '.x_medkit_replaces_executable == "broken_lidar_node"' >/dev/null 2>&1; then + pass "detail has x_medkit_replaces_executable = broken_lidar_node" + else + fail "detail has x_medkit_replaces_executable" "field missing" + fi +fi + +section "Initial process state" + +assert_process_running "/lib/broken_lidar/broken_lidar_node" "broken_lidar_node running before update" +assert_process_running "broken_lidar_legacy" "broken_lidar_legacy running before uninstall" + +section "Update flow: PUT /updates/fixed_lidar_2_1_0/prepare + /execute" + +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API_BASE}/updates/fixed_lidar_2_1_0/prepare" >/dev/null +sleep 4 +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API_BASE}/updates/fixed_lidar_2_1_0/execute" >/dev/null +sleep 6 + +if api_get "/updates/fixed_lidar_2_1_0/status"; then + if echo "$RESPONSE" | jq -e '.status == "completed"' >/dev/null 2>&1; then + pass "fixed_lidar_2_1_0 status is completed" + else + fail "fixed_lidar_2_1_0 status is completed" "got $(echo "$RESPONSE" | jq -c .)" + fi +fi + +assert_process_gone "/lib/broken_lidar/broken_lidar_node" "broken_lidar_node killed after update" +assert_process_running "/lib/fixed_lidar/fixed_lidar_node" "fixed_lidar_node spawned after update" + +section "Install flow: PUT /updates/obstacle_classifier_v2_1_0_0/prepare + /execute" + +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API_BASE}/updates/obstacle_classifier_v2_1_0_0/prepare" >/dev/null +sleep 4 +curl -fsS -X PUT -H 'Content-Type: application/json' -d '{}' \ + "${API_BASE}/updates/obstacle_classifier_v2_1_0_0/execute" >/dev/null +sleep 5 + +assert_process_running "obstacle_classifier_node" "obstacle_classifier_node spawned after install" From 5baa086e3e1fb64185b666b48363ea4999c3387c Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 10:52:31 +0200 Subject: [PATCH 32/36] fix(demos/ota): rename unused loop var in run-demo to satisfy shellcheck SC2034 --- demos/ota_nav2_sensor_fix/run-demo.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/demos/ota_nav2_sensor_fix/run-demo.sh b/demos/ota_nav2_sensor_fix/run-demo.sh index 1989ab9..143b9df 100755 --- a/demos/ota_nav2_sensor_fix/run-demo.sh +++ b/demos/ota_nav2_sensor_fix/run-demo.sh @@ -103,7 +103,7 @@ fi echo "" echo "[3/3] Waiting for gateway to come up..." -for i in 1 2 3 4 5 6 7 8 9 10 11 12; do +for _ in 1 2 3 4 5 6 7 8 9 10 11 12; do if curl -fsS "${GATEWAY_URL}/api/v1/health" >/dev/null 2>&1; then break fi From bf6d5401330e1803753f0ed191374167b5023b28 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 11:03:00 +0200 Subject: [PATCH 33/36] ci(ota): build artifacts inside ros:jazzy container instead of installing ROS on runner The previous step installed ros-jazzy-ros-base + colcon on the runner, but that did not pull in python3-catkin-pkg, so the colcon build inside build_artifacts.sh tripped 'No module named catkin_pkg'. Easier and more faithful to how end-users build the demo: run build_artifacts.sh inside a ros:jazzy container with the demo dir bind-mounted, install only the build deps that the script actually needs, and chown the resulting catalog/tarballs back to the runner user. --- .github/workflows/ci.yml | 52 +++++++++++++++++++++------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0a009f7..0432951 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -184,31 +184,35 @@ jobs: - name: Checkout repository uses: actions/checkout@v4 - - name: Set up Python 3.11 - uses: actions/setup-python@v5 - with: - python-version: "3.11" - - - name: Install pack_artifact dev deps - working-directory: demos/ota_nav2_sensor_fix/scripts - run: | - python -m venv .venv - .venv/bin/pip install --upgrade pip - .venv/bin/pip install pytest - - - name: Set up ROS 2 Jazzy (host) for build_artifacts.sh - run: | - sudo apt-get update - sudo apt-get install -y software-properties-common curl - sudo add-apt-repository universe -y - sudo curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg - echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(. /etc/os-release && echo $UBUNTU_CODENAME) main" | sudo tee /etc/apt/sources.list.d/ros2.list >/dev/null - sudo apt-get update - sudo apt-get install -y ros-jazzy-ros-base python3-colcon-common-extensions - - - name: Build artifacts (catalog + tarballs) + - name: Build artifacts (catalog + tarballs) inside ros:jazzy working-directory: demos/ota_nav2_sensor_fix - run: ./scripts/build_artifacts.sh + 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 From e89628e9e3b0f195bc908976213e81667ee1b633 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 15:57:23 +0200 Subject: [PATCH 34/36] docs: list ota_nav2_sensor_fix in top-level README + smoke test catalog --- README.md | 31 ++++++++++++++++++++++++++++++- 1 file changed, 30 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index c21c948..164af8b 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ All demos support: | [TurtleBot3 Integration](demos/turtlebot3_integration/) | Full ros2_medkit integration with TurtleBot3 and Nav2 | SOVD-compliant API, manifest-based discovery, fault management | ✅ Ready | | [MoveIt Pick-and-Place](demos/moveit_pick_place/) | Panda 7-DOF arm with MoveIt 2 manipulation and ros2_medkit | Planning fault detection, controller monitoring, joint limits | ✅ Ready | | [Multi-ECU Aggregation](demos/multi_ecu_aggregation/) | Multi-ECU peer aggregation with 3 ECUs (perception, planning, actuation), mDNS discovery, cross-ECU functions | Peer aggregation, mDNS discovery, cross-ECU functions | ✅ Ready | +| [OTA over SOVD - nav2 sensor fix](demos/ota_nav2_sensor_fix/) | Dev-grade OTA plugin showing the SOVD `/updates` lifecycle - update a broken lidar node, install a new safety classifier, uninstall a deprecated package | SOVD-spec update / install / uninstall, native binary swap, fork+exec process management, Foxglove panel + curl scripts | ✅ Ready | ### Quick Start @@ -150,6 +151,32 @@ cd demos/multi_ecu_aggregation - Unified SOVD-compliant REST API spanning all ECUs - Web UI for browsing aggregated entity hierarchy +#### OTA over SOVD Demo (Dev-grade Update / Install / Uninstall) + +End-to-end demo of the SOVD `/updates` resource: a broken lidar node is +swapped with a fixed version over HTTP, an extra safety classifier is +installed from scratch, and a deprecated package is uninstalled - all +without SSH, all spec-compliant. + +```bash +cd demos/ota_nav2_sensor_fix +./run-demo.sh # build artifacts + bring up gateway/plugin/update server +./check-demo.sh # show registered updates + per-id status + live process state +./trigger-update.sh # broken_lidar -> fixed_lidar (the headline) +./trigger-install.sh # install obstacle_classifier_v2 +./trigger-uninstall.sh # remove broken_lidar_legacy +./stop-demo.sh +``` + +**Features:** + +- Dev-grade `ota_update_plugin` C++ gateway plugin (UpdateProvider + GatewayPlugin) +- SOVD ISO 17978-3 compliant `/updates` resource: kind derived from + `updated_components` / `added_components` / `removed_components` metadata +- Native binary swap + `fork+exec` process management (no containers, no signing) +- Foxglove Studio panel mirrors the same SOVD client patterns as the web UI +- Pairs with the [`ros2_medkit_foxglove_extension`](https://github.com/selfpatch/ros2_medkit_foxglove_extension) Updates panel + ## Getting Started ### Prerequisites @@ -209,9 +236,11 @@ Each demo has automated smoke tests that verify the gateway starts and the REST ./tests/smoke_test.sh # Sensor diagnostics (full API coverage + fault injection + beacons) ./tests/smoke_test_turtlebot3.sh # TurtleBot3 (discovery, data, operations, scripts, triggers, logs) ./tests/smoke_test_moveit.sh # MoveIt pick-and-place (discovery, data, operations, scripts, triggers, logs) +./tests/smoke_test_multi_ecu.sh # Multi-ECU aggregation (per-ECU discovery + aggregated view) +./tests/smoke_test_ota.sh # OTA over SOVD (catalog, /updates spec shape, prepare/execute, process swap) ``` -CI runs all 4 demos in parallel - each job builds the Docker image, starts the container, and runs the smoke tests against it. See [CI workflow](.github/workflows/ci.yml). +CI runs all demos in parallel - each job builds the Docker image, starts the container, and runs the smoke tests against it. See [CI workflow](.github/workflows/ci.yml). ## Related Projects From 43a66e32076a7f1bf2d8f23b8a972b393368bfa8 Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 16:37:28 +0200 Subject: [PATCH 35/36] feat(demos/ota): bake foxglove_bridge into gateway image (port 8765) Without foxglove_bridge there is nothing for Foxglove Studio to subscribe to - no /scan, no /tf, no 3D visual story. The Updates panel itself is just the SOVD HTTP client and works without it, but the broader demo narrative (phantom obstacle visible, robot stuck) needs the topic stream. Adds ros-jazzy-foxglove-bridge to the runtime stage of Dockerfile.gateway, launches it from entrypoint.sh on port 8765 (0.0.0.0), and maps the port through compose with OTA_FOXGLOVE_BRIDGE_PORT override. Verified live: 'Server listening on port 8765' and channels for /scan, /rosout, /fault_manager/events advertised at startup. --- demos/ota_nav2_sensor_fix/Dockerfile.gateway | 3 ++- demos/ota_nav2_sensor_fix/README.md | 26 ++++++++++++++------ demos/ota_nav2_sensor_fix/docker-compose.yml | 1 + demos/ota_nav2_sensor_fix/entrypoint.sh | 7 ++++++ demos/ota_nav2_sensor_fix/run-demo.sh | 5 ++-- 5 files changed, 32 insertions(+), 10 deletions(-) diff --git a/demos/ota_nav2_sensor_fix/Dockerfile.gateway b/demos/ota_nav2_sensor_fix/Dockerfile.gateway index e355e74..024ec19 100644 --- a/demos/ota_nav2_sensor_fix/Dockerfile.gateway +++ b/demos/ota_nav2_sensor_fix/Dockerfile.gateway @@ -59,6 +59,7 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ros-jazzy-visualization-msgs \ ros-jazzy-launch-ros \ ros-jazzy-test-msgs \ + ros-jazzy-foxglove-bridge \ libcpp-httplib-dev \ libsystemd-dev \ nlohmann-json3-dev \ @@ -73,5 +74,5 @@ RUN chmod +x /usr/local/bin/entrypoint.sh ENV ROS_DOMAIN_ID=42 -EXPOSE 8080 +EXPOSE 8080 8765 ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] diff --git a/demos/ota_nav2_sensor_fix/README.md b/demos/ota_nav2_sensor_fix/README.md index 940a800..7024530 100644 --- a/demos/ota_nav2_sensor_fix/README.md +++ b/demos/ota_nav2_sensor_fix/README.md @@ -46,13 +46,25 @@ 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. +## Foxglove Studio visualization + +The gateway container also runs `foxglove_bridge` on port `8765` so +Foxglove Studio can subscribe to ROS 2 topics (e.g. `/scan` from +broken_lidar / fixed_lidar). + +1. Open Foxglove Studio -> **Open connection** -> **Foxglove WebSocket** -> + `ws://localhost:8765`. You should see `/scan` and other topics in the + Topics panel. +2. Install the [`ros2_medkit_foxglove_extension`](https://github.com/selfpatch/ros2_medkit_foxglove_extension) + (`npm run local-install` in that repo, or drag-and-drop the `.foxe` + onto Foxglove). It ships three panels: Entity Browser, Faults Dashboard, + and **ros2_medkit Updates**. +3. Add the **ros2_medkit Updates** panel and set its `baseUrl` to + `http://localhost:8080/api/v1` (or the port you picked via + `OTA_GATEWAY_PORT`). +4. Click **Prepare** and **Execute** in the Updates panel - the same SOVD + endpoints `trigger-update.sh` hits, with progress feedback in the panel + and live `/scan` updates in the 3D scene. ## Adding nav2 / a sim diff --git a/demos/ota_nav2_sensor_fix/docker-compose.yml b/demos/ota_nav2_sensor_fix/docker-compose.yml index 52c8644..e132b38 100644 --- a/demos/ota_nav2_sensor_fix/docker-compose.yml +++ b/demos/ota_nav2_sensor_fix/docker-compose.yml @@ -16,6 +16,7 @@ services: networks: [otanet] ports: - "${OTA_GATEWAY_PORT:-8080}:8080" + - "${OTA_FOXGLOVE_BRIDGE_PORT:-8765}:8765" environment: ROS_DOMAIN_ID: 42 depends_on: diff --git a/demos/ota_nav2_sensor_fix/entrypoint.sh b/demos/ota_nav2_sensor_fix/entrypoint.sh index 5cf39f1..c9e356a 100755 --- a/demos/ota_nav2_sensor_fix/entrypoint.sh +++ b/demos/ota_nav2_sensor_fix/entrypoint.sh @@ -18,6 +18,13 @@ source /ws/install/setup.bash ros2 run broken_lidar broken_lidar_node & ros2 run broken_lidar_legacy broken_lidar_legacy & +# foxglove_bridge: WebSocket server on :8765 so Foxglove Studio can +# subscribe to /scan, /tf, and any topic the demo nodes publish. Required +# for the visual narrative (3D scene + phantom obstacle); the SOVD Updates +# panel itself only needs the gateway HTTP API. +ros2 run foxglove_bridge foxglove_bridge \ + --ros-args -p port:=8765 -p address:=0.0.0.0 & + # 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). diff --git a/demos/ota_nav2_sensor_fix/run-demo.sh b/demos/ota_nav2_sensor_fix/run-demo.sh index 143b9df..0b45633 100755 --- a/demos/ota_nav2_sensor_fix/run-demo.sh +++ b/demos/ota_nav2_sensor_fix/run-demo.sh @@ -119,8 +119,9 @@ fi echo "" echo "Demo is up." echo "" -echo " Gateway HTTP API: ${GATEWAY_URL}/api/v1/" -echo " Update server: http://localhost:9000/catalog" +echo " Gateway HTTP API: ${GATEWAY_URL}/api/v1/" +echo " Foxglove WebSocket: ws://localhost:${OTA_FOXGLOVE_BRIDGE_PORT:-8765}" +echo " Update server: http://localhost:9000/catalog" echo "" echo "Registered updates:" if command -v jq >/dev/null 2>&1; then From 26e61f43856163aa7289c350ff65a88a8ed1f29a Mon Sep 17 00:00:00 2001 From: Bartosz Burda Date: Mon, 27 Apr 2026 17:21:20 +0200 Subject: [PATCH 36/36] fix(demos/ota): launch fault_manager_node so /faults endpoint responds Without /fault_manager/* services running, the gateway's /faults endpoint hangs waiting for the service call (default 5s timeout) and the Faults Dashboard panel surfaces it as 503. Adds 'ros2 run ros2_medkit_fault_manager fault_manager_node' to entrypoint.sh - the gateway image already builds the package; we just need to run it. Defaults are fine for the demo (SQLite at /var/lib/ros2_medkit/faults.db, snapshot capture enabled). --- demos/ota_nav2_sensor_fix/entrypoint.sh | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/demos/ota_nav2_sensor_fix/entrypoint.sh b/demos/ota_nav2_sensor_fix/entrypoint.sh index c9e356a..ff04742 100755 --- a/demos/ota_nav2_sensor_fix/entrypoint.sh +++ b/demos/ota_nav2_sensor_fix/entrypoint.sh @@ -15,6 +15,12 @@ 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. +# Fault manager: serves /fault_manager/* services that the gateway's +# /faults endpoint calls. Without it /faults hangs because the gateway +# blocks waiting for the service. Default parameters are fine for the +# demo (in-memory store, no persistence). +ros2 run ros2_medkit_fault_manager fault_manager_node & + ros2 run broken_lidar broken_lidar_node & ros2 run broken_lidar_legacy broken_lidar_legacy &