Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
31cfb20
feat(gateway): add GET /apps/{id}/belongs-to discovery endpoint
bburda May 13, 2026
d989583
test(gateway): poll for trigger expiry, split app capability test
bburda May 13, 2026
b77f7a5
fix(gateway): expose belongs-to in app _links for HATEOAS discoverabi…
bburda May 13, 2026
e5db563
test(gateway): join spin thread before reading get_*() results
bburda May 14, 2026
3e35da3
ci(coverage): install gpg required by codecov-action
bburda May 14, 2026
9dcce7a
test(integration): widen gateway shutdown timeouts for sanitizer over…
bburda May 14, 2026
f83d8cc
fix(plugins): make destructors noexcept and guard rclcpp resource reset
bburda May 14, 2026
4727fe0
fix(gateway): allow non-admin roles to query app relationship endpoints
bburda May 14, 2026
aaef97c
fix(gateway): surface broken parent component on apps/{id}/belongs-to
bburda May 14, 2026
97c654c
fix(gateway): align belongs-to advertising and add atomic entity snap…
bburda May 14, 2026
c71c73b
docs(api): remove cross-project sphinx ref from belongs-to description
bburda May 14, 2026
dfc4dae
fix(gateway): atomic snapshot for app dependencies + integration edge…
bburda May 14, 2026
ee86ef3
test(integration): account for HATEOAS edge-case manifest fixtures in…
bburda May 14, 2026
c220212
test(integration): extend manifest_only allow-list with HATEOAS edge …
bburda May 14, 2026
763888a
ci(quality): build ASan with RelWithDebInfo and free more container disk
bburda May 14, 2026
d6b9bc1
ci(quality): revert aggressive doc/man/locale cleanup that broke ROS …
bburda May 14, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -280,7 +280,8 @@ jobs:
- name: Install dependencies
run: |
apt-get update
apt-get install -y lcov ros-jazzy-test-msgs
# gpg is required by codecov/codecov-action@v5 dependency check
apt-get install -y lcov ros-jazzy-test-msgs gpg
source /opt/ros/jazzy/setup.bash
rosdep update
rosdep install --from-paths src --ignore-src -r -y
Expand Down
13 changes: 12 additions & 1 deletion .github/workflows/quality.yml
Original file line number Diff line number Diff line change
Expand Up @@ -229,8 +229,11 @@ jobs:
steps:
- name: Free disk space
run: |
# Host paths are no-ops inside the container - safe to keep
# for the case where the workflow is run without `container:`.
rm -rf /usr/local/lib/android /usr/share/dotnet /opt/ghc || true
apt-get clean
df -h /

- name: Install Git
run: |
Expand Down Expand Up @@ -271,10 +274,18 @@ jobs:
CCACHE_SLOPPINESS: pch_defines,time_macros
run: |
source /opt/ros/jazzy/setup.bash
# RelWithDebInfo (not Debug): Debug uses -O0 which produces
# binaries ~2x the size of -O1, and the ASan/UBSan instrumentation
# already inflates them several times over. On github-hosted
# runners (~14 GB /) the linker hits 'No space left on device' on
# PR branches that miss the ccache warm-up. The sanitizer cmake
# module overrides optimisation back to -O1 either way, so this
# only changes debug info size.
colcon build --symlink-install \
--cmake-args -DCMAKE_BUILD_TYPE=Debug -DSANITIZER=asan,ubsan \
--cmake-args -DCMAKE_BUILD_TYPE=RelWithDebInfo -DSANITIZER=asan,ubsan \
--event-handlers console_direct+
ccache -s
df -h /

- name: Extend test timeouts for ASan overhead
run: |
Expand Down
43 changes: 43 additions & 0 deletions docs/api/rest.rst
Original file line number Diff line number Diff line change
Expand Up @@ -277,6 +277,49 @@ Apps

Unknown apps return ``404 App not found`` with ``parameters.app_id``.

``GET /api/v1/apps/{app_id}/belongs-to``
Return the area that contains this app via its parent component.

Per SOVD (ISO 17978-3 §7.6), the corresponding
``belongs-to`` URI reference in ``GET /apps/{app_id}`` is only emitted when
the app has a parent component (i.e. is not standalone). Standalone apps do
not expose this subresource in HATEOAS and the endpoint will return an empty
``items`` collection if called directly.

The response follows the standard ``items`` wrapper and returns:

- ``0`` items when the app has no associated host component (standalone app)
- ``0`` items when the parent component has no assigned area
- ``1`` item when the area is resolved
- ``1`` item with ``x-medkit.missing=true`` when the parent component references
an area that cannot currently be resolved
- ``1`` item with ``x-medkit.missing=true`` and ``x-medkit.unresolved_component``
set to the dangling component id when the app references a parent component
that cannot currently be resolved (manifest broken / component removed)

**Example Response:**

.. code-block:: json

{
"items": [
{
"id": "engine",
"name": "Engine",
"href": "/api/v1/areas/engine"
}
],
"x-medkit": {
"total_count": 1
},
"_links": {
"self": "/api/v1/apps/engine-temp-sensor/belongs-to",
"app": "/api/v1/apps/engine-temp-sensor"
}
}

Unknown apps return ``404 App not found`` with ``parameters.app_id``.

Functions
~~~~~~~~~

Expand Down
10 changes: 10 additions & 0 deletions docs/requirements/specs/discovery.rst
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,13 @@ Discovery
:tags: Discovery

The endpoint shall return the component that hosts the addressed application.

.. req:: GET /apps/{id}/belongs-to
:id: REQ_INTEROP_106
:status: verified
:tags: Discovery

The endpoint shall return the area that contains the addressed application,
resolved transitively via the app's parent component and that component's
area assignment. The response uses the standard ``items`` wrapper and
contains zero or one element.
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ class ParameterBeaconPlugin : public ros2_medkit_gateway::GatewayPlugin,
public ros2_medkit_gateway::IntrospectionProvider {
public:
ParameterBeaconPlugin() = default;
~ParameterBeaconPlugin() override;
~ParameterBeaconPlugin() noexcept override;

/// Constructor with injectable client factory (for testing).
explicit ParameterBeaconPlugin(ros2_medkit_param_beacon::ParameterClientFactory factory)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,8 +34,14 @@ using ros2_medkit_gateway::PLUGIN_API_VERSION;
using ros2_medkit_gateway::PluginContext;
using ros2_medkit_gateway::SovdEntityType;

ParameterBeaconPlugin::~ParameterBeaconPlugin() {
shutdown();
ParameterBeaconPlugin::~ParameterBeaconPlugin() noexcept {
// On Rolling, ~rclcpp::Node can throw graph_listener::NodeNotFoundError
// once rclcpp::shutdown() has invalidated the context. An exception
// escaping a destructor calls std::terminate(), so swallow it here.
try {
shutdown();
} catch (...) {
}
}

std::string ParameterBeaconPlugin::name() const {
Expand Down Expand Up @@ -151,7 +157,14 @@ void ParameterBeaconPlugin::shutdown() {
backoff_counts_.clear();
skip_remaining_.clear();
}
param_node_.reset();
// ~rclcpp::Node can throw graph_listener::NodeNotFoundError on Rolling
// when the context was already torn down by rclcpp::shutdown(). Swallow
// it so the plugin_manager shutdown sequence (and the plugin destructor
// that calls back into us) does not abort the process.
try {
param_node_.reset();
} catch (...) {
}
}

std::vector<GatewayPlugin::PluginRoute> ParameterBeaconPlugin::get_routes() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ class TopicBeaconPlugin : public ros2_medkit_gateway::GatewayPlugin, public ros2
void configure(const nlohmann::json & config) override;
void set_context(ros2_medkit_gateway::PluginContext & context) override;
void shutdown() override;
~TopicBeaconPlugin() override;
~TopicBeaconPlugin() noexcept override;
std::vector<ros2_medkit_gateway::GatewayPlugin::PluginRoute> get_routes() override;
ros2_medkit_gateway::IntrospectionResult introspect(const ros2_medkit_gateway::IntrospectionInput & input) override;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,14 @@ using ros2_medkit_gateway::PLUGIN_API_VERSION;
using ros2_medkit_gateway::PluginContext;
using ros2_medkit_gateway::SovdEntityType;

TopicBeaconPlugin::~TopicBeaconPlugin() {
shutdown();
TopicBeaconPlugin::~TopicBeaconPlugin() noexcept {
// On Rolling, ~rclcpp::Subscription can throw graph_listener::NodeNotFoundError
// once rclcpp::shutdown() has invalidated the context. An exception
// escaping a destructor calls std::terminate(), so swallow it here.
try {
shutdown();
} catch (...) {
}
}

std::string TopicBeaconPlugin::name() const {
Expand Down Expand Up @@ -107,7 +113,13 @@ void TopicBeaconPlugin::shutdown() {
if (shutdown_requested_.exchange(true)) {
return;
}
subscription_.reset();
// ~rclcpp::Subscription can throw on Rolling when the rclcpp context
// was torn down before us; swallow so plugin_manager shutdown and the
// plugin destructor calling back into us do not abort the process.
try {
subscription_.reset();
} catch (...) {
}
}

std::vector<GatewayPlugin::PluginRoute> TopicBeaconPlugin::get_routes() {
Expand Down
44 changes: 44 additions & 0 deletions src/ros2_medkit_gateway/config/examples/demo_nodes_manifest.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,14 @@ areas:
namespace: /perception/lidar
description: "LiDAR sensor system"

# Isolated area used by HATEOAS edge-case manifest entries below; not bound
# to a running ROS 2 node, exists only to give the unresolved/standalone/
# area-less tests deterministic targets.
- id: hateoas-edge-area
name: "HATEOAS Edge Area"
namespace: /hateoas_edge
description: "Reserved for integration test edge cases"

# =============================================================================
# COMPONENTS - Hardware units
# =============================================================================
Expand Down Expand Up @@ -135,6 +143,20 @@ components:
name: "LiDAR Unit"
type: "sensor"
area: lidar

# HATEOAS edge-case fixtures (manifest-only, no runtime nodes).
# See test_hateoas.test.py for the corresponding test cases.
- id: hateoas-component-no-area
name: "HATEOAS Edge: component without area"
type: "sensor"
# area intentionally omitted - exercises the
# 'parent component resolved but has no area_id' branch.

# Note: the 'dangling area reference' branch (component points at a
# non-existent area id) is covered by unit test
# AppBelongsToReturnsMissingItemWhenAreaUnresolved. We can't seed a
# component with a missing area_id here because manifest validator R006
# rejects it and disables manifest-driven discovery for the whole suite.
description: "360° laser scanner with fault reporting"

# =============================================================================
Expand Down Expand Up @@ -231,6 +253,28 @@ apps:
node_name: lidar_sensor
namespace: /perception/lidar

# === HATEOAS edge-case apps (manifest-only, no ros_binding) ===
# Each targets a specific branch of handle_app_belongs_to /
# handle_app_is_located_on so the integration suite exercises the same
# shapes the unit tests cover.
- id: hateoas-app-standalone
name: "HATEOAS Edge: standalone app"
category: "fixture"
description: "App without a parent component (is_located_on omitted)"

# Note: the 'unresolved parent component' branch is covered by unit test
# AppBelongsToReturnsMissingItemWhenParentComponentUnresolved instead - the
# manifest validator (R007) rejects apps that reference a non-existent
# component even with manifest_strict_validation=false, so we can't seed it
# here as an integration fixture without disabling discovery for the rest
# of the suite.

- id: hateoas-app-on-area-less-component
name: "HATEOAS Edge: app on component without area"
category: "fixture"
description: "Parent component resolves but has no area_id assigned"
is_located_on: hateoas-component-no-area

# =============================================================================
# FUNCTIONS - High-level capabilities
# =============================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ class CapabilityBuilder {
RELATED_APPS, ///< Entity has related apps (components only)
HOSTS, ///< Entity has host apps (functions/components)
DEPENDS_ON, ///< Entity has dependencies (components only)
IS_LOCATED_ON, ///< Entity has parent component (apps only)
BELONGS_TO, ///< Entity has parent area (apps only)
LOGS, ///< Entity has application log entries (components and apps)
BULK_DATA, ///< Entity has bulk data endpoints (rosbags)
CYCLIC_SUBSCRIPTIONS, ///< Entity has cyclic subscription endpoints
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,12 @@ class DiscoveryHandlers {
*/
void handle_app_is_located_on(const httplib::Request & req, httplib::Response & res);

/**
* @brief Handle GET /apps/{app-id}/belongs-to - get parent area via component.
* @verifies REQ_INTEROP_106
*/
void handle_app_belongs_to(const httplib::Request & req, httplib::Response & res);

// =========================================================================
// Function endpoints
// =========================================================================
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,46 @@ class ThreadSafeEntityCache {
std::optional<App> get_app(const std::string & id) const;
std::optional<Function> get_function(const std::string & id) const;

/// Atomic snapshot of an App together with its parent Component and Area.
///
/// Three sequential get_app/get_component/get_area calls each acquire a
/// fresh shared_lock and a writer refresh can advance the generation
/// between them, so handlers that traverse App -> Component -> Area can
/// observe a mixed-generation view (e.g. app from gen N, component from
/// N+1, area from N). This helper resolves the chain under a single
/// shared_lock so the result is internally consistent.
///
/// `app` is empty if `app_id` is not in the cache. `component` is empty if
/// the app has no `component_id` or the referenced component is missing.
/// `area` is empty if the component has no `area` or the referenced area
/// is missing. The two latter cases are distinguishable from "no parent"
/// because `app.component_id` / `component.area` remain set on the
/// returned models.
struct AppLinksSnapshot {
std::optional<App> app;
std::optional<Component> component;
std::optional<Area> area;
};
AppLinksSnapshot get_app_with_links(const std::string & id) const;

/// Atomic snapshot of an App together with its declared `depends_on` apps.
///
/// `handle_app_depends_on` iterates `app.depends_on` and resolves each id
/// via `get_app()` - one shared_lock per dependency. A writer refresh can
/// land between the app fetch and any of the per-dependency fetches, so a
/// 5-dependency app can return 5 apps from 5 different cache generations.
/// This helper takes a single shared_lock and returns the app together
/// with every dependency resolved in the same generation.
///
/// `app` is empty if `app_id` is not in the cache. `dependencies` lists
/// every entry in `app->depends_on` in declaration order; the optional is
/// empty when the referenced dependency cannot be resolved (broken ref).
struct AppDependenciesSnapshot {
std::optional<App> app;
std::vector<std::pair<std::string, std::optional<App>>> dependencies;
};
AppDependenciesSnapshot get_app_with_dependencies(const std::string & id) const;

// --- Check existence (O(1)) ---
bool has_area(const std::string & id) const;
bool has_component(const std::string & id) const;
Expand Down
6 changes: 6 additions & 0 deletions src/ros2_medkit_gateway/src/core/auth/auth_config.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ const std::unordered_map<UserRole, std::unordered_set<std::string>> & AuthConfig
"GET:/api/v1/components/*/hosts",
"GET:/api/v1/components/*/depends-on",
"GET:/api/v1/apps/*/depends-on",
"GET:/api/v1/apps/*/is-located-on",
"GET:/api/v1/apps/*/belongs-to",
"GET:/api/v1/functions/*/hosts",
// Data: all entity types
"GET:/api/v1/components/*/data",
Expand Down Expand Up @@ -186,6 +188,8 @@ const std::unordered_map<UserRole, std::unordered_set<std::string>> & AuthConfig
"GET:/api/v1/components/*/hosts",
"GET:/api/v1/components/*/depends-on",
"GET:/api/v1/apps/*/depends-on",
"GET:/api/v1/apps/*/is-located-on",
"GET:/api/v1/apps/*/belongs-to",
"GET:/api/v1/functions/*/hosts",
// Data: all entity types (read)
"GET:/api/v1/components/*/data",
Expand Down Expand Up @@ -386,6 +390,8 @@ const std::unordered_map<UserRole, std::unordered_set<std::string>> & AuthConfig
"GET:/api/v1/components/*/hosts",
"GET:/api/v1/components/*/depends-on",
"GET:/api/v1/apps/*/depends-on",
"GET:/api/v1/apps/*/is-located-on",
"GET:/api/v1/apps/*/belongs-to",
"GET:/api/v1/functions/*/hosts",
// Data: all entity types (read)
"GET:/api/v1/components/*/data",
Expand Down
1 change: 1 addition & 0 deletions src/ros2_medkit_gateway/src/core/discovery/models/app.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,7 @@ json App::to_capabilities(const std::string & base_url) const {
// Relationships (SOVD standard)
if (!component_id.empty()) {
j["is-located-on"] = base_url + "/components/" + component_id;
j["belongs-to"] = app_base + "/belongs-to";
}
if (!depends_on.empty()) {
j["depends-on"] = app_base + "/depends-on";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,10 @@ std::string CapabilityBuilder::capability_to_name(Capability cap) {
return "hosts";
case Capability::DEPENDS_ON:
return "depends-on";
case Capability::IS_LOCATED_ON:
return "is-located-on";
case Capability::BELONGS_TO:
return "belongs-to";
case Capability::LOGS:
return "logs";
case Capability::BULK_DATA:
Expand Down
14 changes: 10 additions & 4 deletions src/ros2_medkit_gateway/src/core/models/entity_capabilities.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -29,8 +29,11 @@ EntityCapabilities EntityCapabilities::for_type(SovdEntityType type) {
ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS,
ResourceCollection::UPDATES,
};
// SERVER resources
caps.resources_ = {"docs", "version-info", "logs", "belongs-to", "depends-on", "data-categories", "data-groups"};
// SERVER resources. SOVD (ISO 17978-3 §7.6) does not define
// /belongs-to for server, only for apps - advertising it here would
// make supports_resource("belongs-to") return true and clients would
// get 404 when following it.
caps.resources_ = {"docs", "version-info", "logs", "depends-on", "data-categories", "data-groups"};
break;

case SovdEntityType::AREA:
Expand All @@ -56,8 +59,11 @@ EntityCapabilities EntityCapabilities::for_type(SovdEntityType type) {
ResourceCollection::LOGS, ResourceCollection::TRIGGERS, ResourceCollection::SCRIPTS,
ResourceCollection::UPDATES,
};
caps.resources_ = {"docs", "logs", "hosts", "belongs-to",
"depends-on", "subcomponents", "data-categories", "data-groups"};
// SOVD (ISO 17978-3 §7.6) defines /belongs-to only for apps; component
// exposes parent area via /is-located-on (which is itself app-only in
// the spec, but ros2_medkit treats it as the canonical area pointer).
// Listing belongs-to here would be a 404 promise.
caps.resources_ = {"docs", "logs", "hosts", "depends-on", "subcomponents", "data-categories", "data-groups"};
break;

case SovdEntityType::APP:
Expand Down
Loading
Loading