diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index b207783c2..383fb5e0c 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -83,7 +83,7 @@ void ExampleHandlers::handle_request(const httplib::Request & req, httplib::Resp // 2. Validate entity (sends error response automatically if invalid) auto entity = ctx_.validate_entity_for_route(req, res, entity_id); - if (!entity) return; // Error already sent + if (!entity) return; // Response already sent (error or forwarded to peer) // 3. Check collection support (static method) if (auto err = HandlerContext::validate_collection_access(*entity, ResourceCollection::DATA)) { @@ -110,7 +110,7 @@ void ExampleHandlers::handle_request(const httplib::Request & req, httplib::Resp | Method | Returns | Purpose | |--------|---------|---------| -| `validate_entity_for_route(req, res, id)` | `optional` | Unified entity validation, sends error if invalid | +| `validate_entity_for_route(req, res, id)` | `ValidateResult` (`tl::expected`) | Unified entity validation; returns error or forward outcome on failure | | `validate_collection_access(entity, collection)` | `optional` | Check SOVD capability support | | `send_error(res, status, code, msg, params)` | void | SOVD GenericError response | | `send_json(res, data)` | void | JSON success response | @@ -242,7 +242,7 @@ When reviewing pull requests, apply these rules to the diff. Flag violations as - **Flag** dereferencing a `tl::expected` or `std::optional` result without checking `if (!result)` first. - **Flag** error responses that don't use `HandlerContext::send_error()` or use string literals instead of constants from `error_codes.hpp`. - **Flag** custom vendor error codes that don't start with `x-medkit-` prefix. -- **Flag** any code after `validate_entity_for_route()` that doesn't check for `nullopt` and return early - the error response is already sent by the validator. +- **Flag** any code after `validate_entity_for_route()` that doesn't check for failure and return early - the response is already committed by the validator (either error sent or request forwarded to peer). ### Handler Pattern diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 64d7d8add..9f1499462 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -8,7 +8,7 @@ repos: - id: clang-format name: clang-format types_or: [c, c++] - exclude: /vendored/ + exclude: (/vendored/|/third_party/) # Uses the project's .clang-format automatically # ── CMake ────────────────────────────────────────────────────────────── @@ -60,7 +60,7 @@ repos: entry: ament_copyright language: system types_or: [c, c++, python, cmake] - exclude: /vendored/ + exclude: (/vendored/|/third_party/) # ── Incremental clang-tidy (pre-push only) ──────────────────────── # Requires: pre-commit install --hook-type pre-push @@ -71,5 +71,5 @@ repos: entry: ./scripts/clang-tidy-diff.sh language: system types: [c++] - exclude: /vendored/ + exclude: (/vendored/|/third_party/) stages: [pre-push] diff --git a/docs/config/aggregation.rst b/docs/config/aggregation.rst new file mode 100644 index 000000000..01c2f8f7b --- /dev/null +++ b/docs/config/aggregation.rst @@ -0,0 +1,450 @@ +Aggregation Configuration +========================= + +This reference describes all aggregation-related configuration options for +multi-instance peer aggregation in ros2_medkit_gateway. + +.. contents:: Table of Contents + :local: + :depth: 2 + +Overview +-------- + +Aggregation allows multiple gateway instances to federate their entity trees +into a single unified API. A primary gateway merges entities from peer gateways +and transparently forwards requests for remote entities. + +All aggregation parameters are under the ``aggregation`` key in +``gateway_params.yaml`` or can be set via ROS 2 parameters. + +Quick Start +----------- + +.. code-block:: bash + + # Enable aggregation with a static peer + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p aggregation.enabled:=true \ + -p aggregation.peer_urls:="['http://192.168.1.10:8080']" \ + -p aggregation.peer_names:="['peer_b']" + +Or in ``gateway_params.yaml``: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + aggregation: + enabled: true + peer_urls: ["http://192.168.1.10:8080"] + peer_names: ["peer_b"] + +Core Parameters +--------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 10 10 50 + + * - Parameter + - Type + - Default + - Description + * - ``aggregation.enabled`` + - bool + - ``false`` + - Master switch for peer aggregation. When disabled, the gateway operates + in standalone mode with no peer communication. + * - ``aggregation.timeout_ms`` + - int + - ``2000`` + - HTTP timeout in milliseconds for all peer communication: health checks, + entity fetching, and request forwarding. Increase for high-latency + networks. + +mDNS Discovery Parameters +-------------------------- + +.. list-table:: + :header-rows: 1 + :widths: 30 10 10 50 + + * - Parameter + - Type + - Default + - Description + * - ``aggregation.announce`` + - bool + - ``false`` + - Broadcast this gateway's presence via mDNS. Other gateways on the local + network can discover this instance automatically. Opt-in to avoid + surprising network behavior. + * - ``aggregation.discover`` + - bool + - ``false`` + - Browse for peer gateways via mDNS. When a new peer is found, it is + automatically added to the peer list. Opt-in to avoid surprising + network behavior. + * - ``aggregation.mdns_service`` + - string + - ``"_medkit._tcp.local"`` + - mDNS service type used for announcement and browsing. All gateways in + the same aggregation cluster must use the same service type. + * - ``aggregation.mdns_name`` + - string + - ``""`` + - mDNS instance name for announcement and self-discovery filtering. + Defaults to the system hostname (via ``gethostname()``). Must be unique + per gateway instance. Set explicitly when running multiple gateways on + the same host - otherwise they share the same hostname and filter each + other out as "self". + +.. _aggregation-security: + +Security Parameters +-------------------- + +These parameters control authentication forwarding and transport security +for peer communication. + +.. list-table:: + :header-rows: 1 + :widths: 30 10 10 50 + + * - Parameter + - Type + - Default + - Description + * - ``aggregation.forward_auth`` + - bool + - ``false`` + - Forward the client's ``Authorization`` header to peer gateways. When + ``false`` (default), auth tokens are **never** sent to peers - this + prevents token leakage to untrusted or mDNS-discovered peers. Only + enable when all peers are trusted and share the same JWT configuration. + * - ``aggregation.require_tls`` + - bool + - ``false`` + - Require HTTPS for all peer URLs. When ``true``, any peer URL using + ``http://`` is rejected at startup (static peers) or on discovery + (mDNS peers) with an ERROR log. When ``false``, ``http://`` peers + produce a WARN log about cleartext communication. + * - ``aggregation.peer_scheme`` + - string + - ``"http"`` + - URL scheme used when constructing URLs for mDNS-discovered peers. + mDNS SRV records provide hostname and port but not the URL scheme. + Set to ``"https"`` when all peers use TLS. This does not affect + static peer URLs (which include the scheme explicitly). + * - ``aggregation.max_discovered_peers`` + - int + - ``50`` + - Maximum number of peers that can be added via mDNS discovery. Prevents + unbounded growth of the peer list from rogue mDNS announcements on the + local network. Static peers (configured via ``peer_urls``/``peer_names``) + do not count against this limit. When the limit is reached, additional + discovered peers are rejected with a WARN log. + +.. warning:: + + When ``forward_auth`` is enabled but ``require_tls`` is disabled, the + gateway logs a warning at startup. Authorization tokens may be sent to + peers over cleartext HTTP, exposing them to network sniffers. + +.. warning:: + + When ``forward_auth`` is enabled, **all peers** (including mDNS-discovered + ones) receive the client's auth token. A malicious peer on the local + network could harvest these tokens. For production deployments: + + 1. Set ``forward_auth: true`` only if all peers are trusted. + 2. Set ``require_tls: true`` to prevent tokens from flowing in cleartext. + 3. Set ``peer_scheme: "https"`` so mDNS-discovered peers also use TLS. + 4. Consider disabling mDNS discovery (``discover: false``) and using + only static peers with known ``https://`` URLs. + +Static Peers +------------ + +Static peers are configured as parallel arrays: ``peer_urls[i]`` pairs with +``peer_names[i]``. Both arrays must have the same length. Empty-string entries +are ignored. + +.. list-table:: + :header-rows: 1 + :widths: 30 10 10 50 + + * - Parameter + - Type + - Default + - Description + * - ``aggregation.peer_urls`` + - string[] + - ``[""]`` + - List of peer gateway base URLs (e.g., + ``["http://192.168.1.10:8080", "http://192.168.1.11:8080"]``). + Each URL must include the scheme and port. + * - ``aggregation.peer_names`` + - string[] + - ``[""]`` + - List of human-readable names for peers (e.g., + ``["arm_controller", "base_platform"]``). + Used as prefix for collision resolution (e.g., ``peername__entity_id``) + and in the routing table. Must be unique across all peers. + +Scenario Examples +----------------- + +Star Topology +~~~~~~~~~~~~~ + +One primary gateway aggregates from three subsystem gateways: + +.. code-block:: yaml + + # Primary gateway (host-A, port 8080) + ros2_medkit_gateway: + ros__parameters: + aggregation: + enabled: true + timeout_ms: 3000 + announce: false + discover: false # Use static peers only + peer_urls: ["http://192.168.1.10:8080", "http://192.168.1.11:8080", "http://192.168.1.12:8080"] + peer_names: ["arm_controller", "base_platform", "sensor_array"] + +The leaf gateways do not need aggregation enabled - they serve their own +entities independently. Only the primary gateway needs aggregation. + +Chain Topology +~~~~~~~~~~~~~~ + +Gateway A aggregates from B, which aggregates from C: + +.. code-block:: yaml + + # Gateway A (top-level aggregator) + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: true + peer_urls: ["http://gateway-b:8080"] + peer_names: ["subsystem_b"] + +.. code-block:: yaml + + # Gateway B (mid-level aggregator) + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: true + peer_urls: ["http://gateway-c:8080"] + peer_names: ["subsystem_c"] + +.. code-block:: yaml + + # Gateway C (leaf - no aggregation needed) + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: false + +Gateway A sees entities from A + B + C. Gateway B sees entities from B + C. + +mDNS-Only Discovery +~~~~~~~~~~~~~~~~~~~~ + +Fully automatic peer discovery with no static configuration: + +.. code-block:: yaml + + # All gateways use the same config + ros2_medkit_gateway: + ros__parameters: + aggregation: + enabled: true + announce: true + discover: true + mdns_service: "_medkit._tcp.local" + # No static peers - all discovery via mDNS + +All gateways on the same network segment automatically find each other. When a +gateway starts, it announces itself and discovers existing peers. When a gateway +stops, it sends an mDNS goodbye and peers remove it automatically. + +.. note:: + + mDNS requires multicast network support. Docker containers using bridge + networking may not support mDNS - use static peers or host networking + instead. + +Mixed Static + mDNS +~~~~~~~~~~~~~~~~~~~~ + +Combine static peers for known infrastructure with mDNS for dynamic discovery: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + aggregation: + enabled: true + announce: true + discover: true + # Always connect to the base platform + peer_urls: ["http://base-platform:8080"] + peer_names: ["base_platform"] + # Additional peers discovered via mDNS at runtime + +.. note:: + + By default, ``Authorization`` headers are **not** forwarded to peers + (``forward_auth: false``). If your peers require authentication, set + ``forward_auth: true`` and ensure all peers use the same JWT + configuration. See :ref:`Security Parameters ` + for details on securing peer communication. + +Secure Aggregation (TLS + Auth) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Production deployment with TLS enforcement and auth forwarding: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + server: + tls: + enabled: true + cert_file: "/etc/ros2_medkit/certs/cert.pem" + key_file: "/etc/ros2_medkit/certs/key.pem" + aggregation: + enabled: true + forward_auth: true # All peers share JWT config + require_tls: true # Reject any http:// peers + peer_scheme: "https" # mDNS-discovered peers use HTTPS + announce: true + discover: true + peer_urls: ["https://gateway-b:8080"] + peer_names: ["subsystem_b"] + +Entity Merge Behavior +--------------------- + +When aggregation is enabled, entities from peers are merged with local entities: + +- **Areas, Functions, and Components**: Merged by ID. If both local and remote + have the same ID, they become one entity. Areas and Functions represent logical + groupings that span hosts. Components represent physical hosts or ECUs defined + in manifests - the same Component across peers is the same physical entity. + +- **Apps**: Prefixed on collision. If a remote App has the same ID as a local + one, the remote App's ID is prefixed with ``peername__`` (double underscore). + For example, App ``camera_driver`` from peer ``arm`` becomes + ``arm__camera_driver``. Apps represent individual ROS 2 nodes with unique + behavior. + +Requests for remote entities are transparently forwarded to the owning peer. +The routing table maps entity IDs to peer names. + +See :doc:`../design/ros2_medkit_gateway/aggregation` for detailed merge logic +and architecture diagrams. + +Health and Partial Results +-------------------------- + +Entity collection endpoints (``GET /api/v1/areas``, ``/components``, ``/apps``, +``/functions``) serve data from the local entity cache, which is populated +during periodic cache refresh cycles. These endpoints do not perform real-time +fan-out to peers, so they always return successfully with whatever entities +were last cached. + +The ``GET /api/v1/faults`` endpoint is different - it performs real-time fan-out +via ``fan_out_get()`` to collect faults from all healthy peers. If a peer is +unreachable during this fan-out, the response body includes: + +- ``x-medkit.partial: true`` in the JSON response body +- ``x-medkit.failed_peers`` listing which peers failed + +This allows clients to detect degraded fault responses and take appropriate +action. Individual entity requests for remote entities (e.g., +``GET /api/v1/apps/{id}/data``) return ``502 Bad Gateway`` if the owning peer +is unreachable. + +.. _aggregation-breaking-changes: + +Breaking Changes (Entity Model Simplification) +----------------------------------------------- + +.. warning:: + + These changes affect **all discovery modes**, not just aggregation. + Users running in ``runtime_only`` mode without aggregation enabled will + see different API responses after upgrading. + +The entity model has been aligned with the SOVD specification (ISO 17978). +Synthetic/heuristic Area and Component creation from ROS 2 namespaces has +been removed. The following behavioral changes apply: + +**API response changes:** + +- ``GET /api/v1/components`` now returns a **single host-level Component** + (from ``HostInfoProvider``) instead of one synthetic Component per ROS 2 + namespace. +- ``GET /api/v1/areas`` now returns an **empty list** in runtime-only mode + (was namespace-based). Areas come from manifest only. +- ``GET /api/v1/functions`` is now **populated** in runtime-only mode (was + always empty). Each ROS 2 namespace becomes a Function entity, controlled + by the ``create_functions_from_namespaces`` parameter (default: ``true``). +- **Apps** are still created from ROS 2 nodes with ``source: "heuristic"``. + +**Removed configuration parameters (8 total):** + +The following parameters no longer exist. The gateway will log a warning +and ignore them if present in config. + +From ``discovery.runtime``: + +- ``create_synthetic_areas`` +- ``create_synthetic_components`` +- ``grouping_strategy`` +- ``synthetic_component_name_pattern`` +- ``topic_only_policy`` +- ``min_topics_for_component`` + +From ``discovery.merge_pipeline.gap_fill``: + +- ``allow_heuristic_areas`` +- ``allow_heuristic_components`` + +**Cross-layer impact:** + +The entity model change affects consumers beyond the gateway REST API: + +- **Web UI** (``ros2_medkit_web_ui``): The ``ComponentWithOperations`` type + has stale ``area``, ``namespace``, and ``fqn`` fields that assumed + per-namespace Components. These fields are empty or absent for the single + host-level Component. +- **Foxglove extension** (``ros2_medkit_foxglove_extension``): The + ``EntityBrowserPanel`` loads Areas first to build the entity tree. In + runtime-only mode, the Areas list is now empty, so the panel shows no + top-level grouping until a manifest is provided. +- **MCP server** (``ros2_medkit_mcp``): Clients that reference synthetic + Component IDs (e.g., ``powertrain_engine_component``) will receive 404 + errors. Update tool calls to use the single host-level Component ID or + switch to Apps/Functions. + +**Migration path:** + +- If you relied on per-namespace Components, switch to ``hybrid`` or + ``manifest_only`` mode and declare Components explicitly in a manifest. +- If you relied on namespace-based Areas, declare them in a manifest. +- Namespace grouping is now handled by Function entities instead of Areas + and Components. diff --git a/docs/config/discovery-options.rst b/docs/config/discovery-options.rst index 0b3d0d649..c8862fdba 100644 --- a/docs/config/discovery-options.rst +++ b/docs/config/discovery-options.rst @@ -7,7 +7,7 @@ Discovery Options Reference This document describes configuration options for the gateway's discovery system. The discovery system maps ROS 2 graph entities (nodes, topics, services, actions) -to SOVD entities (areas, components, apps). +to SOVD entities (areas, components, apps, functions). Discovery Modes --------------- @@ -35,100 +35,72 @@ Runtime Discovery Options When using ``runtime_only`` or ``hybrid`` mode, the following options control how ROS 2 nodes are mapped to SOVD entities. -Synthetic Components -^^^^^^^^^^^^^^^^^^^^ +Entity Model +^^^^^^^^^^^^ -.. code-block:: yaml +.. important:: - discovery: - runtime: - create_synthetic_components: true - grouping_strategy: "namespace" - synthetic_component_name_pattern: "{area}" - -When ``create_synthetic_components`` is true: + The entity model changed in this release. Synthetic Areas and per-namespace + Components are no longer created. If you are upgrading from a previous + version, see :ref:`Breaking Changes ` for + details, removed parameters, cross-layer impact, and migration guidance. -- Components are created as logical groupings of Apps -- ``grouping_strategy: "namespace"`` groups nodes by their first namespace segment -- ``synthetic_component_name_pattern`` defines the component ID format +In runtime mode, the gateway maps the ROS 2 graph to SOVD entities as follows: -.. note:: +- **Areas** - not created by runtime discovery. Areas come from manifest only. +- **Components** - a single host-level Component is created from + ``HostInfoProvider`` (see Default Component below). No synthetic/heuristic + Components are created from namespaces. +- **Apps** - each ROS 2 node becomes an App with ``source: "heuristic"``. +- **Functions** - namespace grouping creates Function entities (see below). - When ``create_synthetic_areas`` is false, the ``{area}`` placeholder in - ``synthetic_component_name_pattern`` still resolves to the namespace - segment used as the component grouping key - it does not require areas - to be enabled. - -Synthetic Areas -^^^^^^^^^^^^^^^ +Default Component +^^^^^^^^^^^^^^^^^ .. code-block:: yaml discovery: runtime: - create_synthetic_areas: true - -When ``create_synthetic_areas`` is true (the default): + default_component: + enabled: true -- Areas are created from ROS 2 namespace segments (e.g., ``/powertrain`` becomes area ``powertrain``) -- Components and Apps are organized under these Areas +When ``default_component.enabled`` is true (the default), the gateway creates +a single host-level Component from the local system info (hostname, OS, +architecture) via ``HostInfoProvider``. All discovered Apps are linked to this +Component. -When ``create_synthetic_areas`` is false: - -- No Areas are created from namespaces -- The entity tree is flat - Components are top-level entities -- This is useful for simple robots (e.g., TurtleBot3) where an area hierarchy adds unnecessary nesting +Internal Node Filtering +^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: yaml - # Flat entity tree - no areas discovery: runtime: - create_synthetic_areas: false - -Topic-Only Namespaces -^^^^^^^^^^^^^^^^^^^^^ + filter_internal_nodes: true -Some ROS 2 systems have topics published to namespaces without any associated nodes. -This is common with: +When ``filter_internal_nodes`` is true (the default), ROS 2 nodes whose names +start with an underscore (``_``) are excluded from the entity tree. This +filters out ROS 2 internal infrastructure nodes such as ``_ros2cli_*``, +``_param_client_node``, and similar system nodes that should not appear as +SOVD entities. The filter applies to both locally discovered Apps and +peer-discovered Apps (after stripping the peer prefix). -- Isaac Sim and other simulators -- External bridges (MQTT, Zenoh, ROS 1) -- Dead/orphaned topics from crashed processes +Set to ``false`` if you need to expose all ROS 2 nodes regardless of naming +convention. -The ``topic_only_policy`` controls how these namespaces are handled: +Function Entities from Namespaces +^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ .. code-block:: yaml discovery: runtime: - topic_only_policy: "create_component" - min_topics_for_component: 1 + create_functions_from_namespaces: true -.. list-table:: Topic-Only Policies - :header-rows: 1 - :widths: 25 75 - - * - Policy - - Description - * - ``ignore`` - - Don't create any entities for topic-only namespaces. - Use this to reduce noise from orphaned topics. - * - ``create_component`` - - Create a Component with ``source: "topic"`` for each topic-only - namespace. (default) - * - ``create_area_only`` - - Only create the Area, but don't create a Component. - Useful when you want the namespace visible but not as a component. - -The ``min_topics_for_component`` parameter (default: 1) sets the minimum number -of topics required before creating a component. This can filter out namespaces -with only a few stray topics. - -.. note:: - - ``create_area_only`` has no effect when ``create_synthetic_areas: false`` - - use ``ignore`` instead. +When ``create_functions_from_namespaces`` is true (the default), each ROS 2 +namespace becomes a Function entity that groups the Apps in that namespace. +This is the SOVD-correct mapping where Functions represent logical capabilities +(what the software does) rather than deployment topology. Merge Pipeline (Hybrid Mode) ----------------------------- @@ -237,13 +209,16 @@ to create: discovery: merge_pipeline: gap_fill: - allow_heuristic_areas: true - allow_heuristic_components: true allow_heuristic_apps: true allow_heuristic_functions: false # namespace_blacklist: ["/rosout"] # namespace_whitelist: [] +.. note:: + + Areas and Components are never created by runtime discovery. Areas come + from manifest only. Components come from ``HostInfoProvider`` or manifest. + .. list-table:: :header-rows: 1 :widths: 35 15 50 @@ -251,12 +226,6 @@ to create: * - Parameter - Default - Description - * - ``allow_heuristic_areas`` - - ``true`` - - Create areas from namespaces not in manifest. - * - ``allow_heuristic_components`` - - ``true`` - - Create synthetic components for unmanifested namespaces. * - ``allow_heuristic_apps`` - ``true`` - Create apps for nodes without manifest ``ros_binding``. @@ -313,23 +282,16 @@ Complete YAML configuration for runtime discovery: mode: "runtime_only" runtime: - # Create Areas from namespace segments - create_synthetic_areas: true - - # Group Apps into Components by namespace - create_synthetic_components: true - grouping_strategy: "namespace" - synthetic_component_name_pattern: "{area}" + # Function entities from namespace grouping (default: true) + create_functions_from_namespaces: true - # Handle topic-only namespaces - topic_only_policy: "create_component" - min_topics_for_component: 2 # Require at least 2 topics + # Single host-level Component (default: true) + default_component: + enabled: true # Merge pipeline (hybrid mode only) merge_pipeline: gap_fill: - allow_heuristic_areas: true - allow_heuristic_components: true allow_heuristic_apps: true namespace_blacklist: ["/rosout"] @@ -341,8 +303,7 @@ Override discovery options via command line: .. code-block:: bash ros2 launch ros2_medkit_gateway gateway.launch.py \ - discovery.runtime.topic_only_policy:="ignore" \ - discovery.runtime.min_topics_for_component:=3 + discovery.runtime.create_functions_from_namespaces:=false Discovery Mechanism Selection ----------------------------- diff --git a/docs/config/index.rst b/docs/config/index.rst index 28df9d5b4..c7f084b81 100644 --- a/docs/config/index.rst +++ b/docs/config/index.rst @@ -11,6 +11,7 @@ This section contains configuration references for ros2_medkit. manifest-schema fault-manager diagnostic-bridge + aggregation Server Configuration -------------------- @@ -46,3 +47,10 @@ Diagnostic Bridge :doc:`diagnostic-bridge` Diagnostic bridge configuration for converting standard ROS 2 diagnostics to fault events. Includes custom fault code mappings. + +Aggregation +----------- + +:doc:`aggregation` + Multi-instance peer aggregation configuration: static peers, mDNS discovery, + health monitoring, and entity merge settings. diff --git a/docs/config/manifest-schema.rst b/docs/config/manifest-schema.rst index 02a734905..81f520f2a 100644 --- a/docs/config/manifest-schema.rst +++ b/docs/config/manifest-schema.rst @@ -778,19 +778,10 @@ represents the robot itself, with subcomponents for hardware modules: For manifest-based discovery (``manifest_only`` or ``hybrid``), simply omit the ``areas:`` section as shown above - no additional configuration is needed. -For runtime-only discovery, set ``create_synthetic_areas: false`` in -``gateway_params.yaml`` to prevent automatic area creation from namespaces (see -:doc:`discovery-options`). A complete example is available at +In runtime-only discovery, Areas are never created - they come from manifest +only. A complete example is available at ``config/examples/flat_robot_manifest.yaml`` in the gateway package. -.. note:: - - In hybrid mode, the runtime gap-fill layer may create synthetic areas - from namespaces even when the manifest omits ``areas:``. Set - ``merge_pipeline.gap_fill.allow_heuristic_areas: false`` to prevent - this and maintain a fully flat tree. See :doc:`discovery-options` - for gap-fill configuration details. - Complete Example ---------------- diff --git a/docs/getting_started.rst b/docs/getting_started.rst index 7d9b6538d..9a7da9db2 100644 --- a/docs/getting_started.rst +++ b/docs/getting_started.rst @@ -171,28 +171,36 @@ ros2_medkit organizes ROS 2 nodes into a SOVD-aligned entity hierarchy: **Discovery Modes** - - **Runtime-only** (default): Each ROS 2 namespace becomes an Area, and - ROS 2 nodes within it are exposed as Apps. Synthetic Components are - created to group these Apps by namespace. + - **Runtime-only** (default): ROS 2 nodes are exposed as Apps. A single + host-level Component is created from system info. Namespace prefixes + create Functions that group related Apps. - **Hybrid**: Manifest defines Areas/Components/Apps/Functions, runtime links them to live ROS 2 nodes. - **Manifest-only**: Only manifest-declared entities are exposed. - Areas are optional. Simple robots can use a flat component tree without - areas by setting ``discovery.runtime.create_synthetic_areas: false`` or - by omitting the ``areas:`` section in the manifest. + Areas are created from manifest only - they are never auto-generated in + runtime mode. Omit the ``areas:`` section in the manifest for a flat tree. See :doc:`tutorials/manifest-discovery` for details on manifest mode. + .. important:: + + If you are upgrading from a previous version, the entity model has + changed significantly. Synthetic Areas and per-namespace Components + are no longer created. See + :ref:`Breaking Changes ` for details + and migration guidance. + In this tutorial, we use runtime-only mode with ``demo_nodes.launch.py``. -**List all areas:** +**List all functions:** .. code-block:: bash - curl http://localhost:8080/api/v1/areas + curl http://localhost:8080/api/v1/functions -With ``demo_nodes.launch.py``, you'll see areas like ``powertrain``, ``chassis``, and ``body``. +With ``demo_nodes.launch.py``, you'll see Functions like ``powertrain``, ``chassis``, and ``body`` +(created from the first namespace segment). **List all components:** @@ -200,11 +208,16 @@ With ``demo_nodes.launch.py``, you'll see areas like ``powertrain``, ``chassis`` curl http://localhost:8080/api/v1/components -**List components in a specific area:** +In runtime mode, you'll see a single host-level Component. + +**List all areas:** .. code-block:: bash - curl http://localhost:8080/api/v1/areas/powertrain/components + curl http://localhost:8080/api/v1/areas + +In runtime mode, this returns an empty list. Areas require a manifest definition +(see :doc:`tutorials/manifest-discovery`). Step 4: Read Sensor Data ------------------------ diff --git a/docs/glossary.rst b/docs/glossary.rst index 4b1202413..cba1b152b 100644 --- a/docs/glossary.rst +++ b/docs/glossary.rst @@ -97,6 +97,7 @@ This glossary defines key terms used throughout ros2_medkit documentation. a subset of SOVD adapted for ROS 2 systems. Synthetic Component - A Component automatically created in runtime-only mode by grouping - ROS 2 nodes that share a namespace. Configured via - ``discovery.runtime.create_synthetic_components``. + (Removed) Previously, Components were automatically created in + runtime-only mode by grouping ROS 2 nodes by namespace. This + behavior has been removed. Components now come from + ``HostInfoProvider`` (single host-level Component) or manifest. diff --git a/docs/tutorials/heuristic-apps.rst b/docs/tutorials/heuristic-apps.rst index 45765b14c..1b1779bce 100644 --- a/docs/tutorials/heuristic-apps.rst +++ b/docs/tutorials/heuristic-apps.rst @@ -108,97 +108,33 @@ All options are under ``discovery.runtime`` in the gateway parameters: mode: "runtime_only" # or "hybrid" runtime: - create_synthetic_components: true - grouping_strategy: "namespace" - synthetic_component_name_pattern: "{area}" + create_functions_from_namespaces: true + default_component: + enabled: true -create_synthetic_components -^^^^^^^^^^^^^^^^^^^^^^^^^^^ +Entity Model +^^^^^^^^^^^^^ -When ``true`` (default), the gateway creates synthetic Components to group Apps: +In runtime mode, the gateway maps the ROS 2 graph as follows: -.. code-block:: bash - - curl http://localhost:8080/api/v1/components - # Returns: [{"id": "perception", "source": "synthetic", ...}] - - curl http://localhost:8080/api/v1/components/perception/apps - # Returns: [{"id": "lidar_driver"}, {"id": "camera_node"}] - -.. note:: - - Synthetic components are **logical groupings only**. They do not aggregate - operations or data from their hosted Apps. To access operations (services/actions), - you must query the Apps within the component: - - .. code-block:: bash - - # List Apps in the component - curl http://localhost:8080/api/v1/components/perception/apps - - # Get operations for a specific App - curl http://localhost:8080/api/v1/apps/lidar_driver/operations - - The component endpoint ``GET /components/{id}/operations`` aggregates operation - listings from all hosted Apps for convenience, but execution must target the - specific App that owns the operation. - -When ``false``, no synthetic Components are created (Apps-only mode): +- **Apps** - each ROS 2 node becomes an App (``source: "heuristic"``) +- **Functions** - namespace grouping creates Function entities +- **Components** - a single host-level Component from ``HostInfoProvider`` +- **Areas** - not created (Areas come from manifest only) .. code-block:: bash - curl http://localhost:8080/api/v1/components - # Returns: [] (empty - no synthetic components) - curl http://localhost:8080/api/v1/apps # Returns: [{"id": "lidar_driver"}, {"id": "camera_node"}] -grouping_strategy -^^^^^^^^^^^^^^^^^ - -Controls how Apps are grouped into Components: - -- ``namespace`` (default): Group by first namespace segment -- ``none``: Each app is its own component - -Handling Topic-Only Namespaces ------------------------------- - -Some systems (like Isaac Sim) publish topics without creating ROS 2 nodes. -The ``topic_only_policy`` controls how these are handled: - -.. code-block:: yaml - - discovery: - runtime: - topic_only_policy: "create_component" - min_topics_for_component: 2 - -Policies: - -- ``create_component`` (default): Create a Component for topic namespaces -- ``create_area_only``: Create only the Area, no Component -- ``ignore``: Skip topic-only namespaces entirely - -Example: Filtering Noise -^^^^^^^^^^^^^^^^^^^^^^^^ - -To ignore orphaned topics from crashed processes: - -.. code-block:: yaml - - discovery: - runtime: - topic_only_policy: "ignore" - -Or require multiple topics before creating a component: + curl http://localhost:8080/api/v1/components + # Returns: [{"id": "my-hostname", "source": "runtime", ...}] -.. code-block:: yaml + curl http://localhost:8080/api/v1/functions + # Returns: [{"id": "perception"}, {"id": "navigation"}] - discovery: - runtime: - topic_only_policy: "create_component" - min_topics_for_component: 3 # Need 3+ topics + curl http://localhost:8080/api/v1/areas + # Returns: {"items": []} (empty - Areas come from manifest only) API Endpoints ------------- diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index faed08ec7..3b1ed1758 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -27,6 +27,7 @@ Step-by-step guides for common use cases with ros2_medkit. plugin-system linux-introspection triggers-use-cases + multi-instance Demos ----- @@ -112,3 +113,6 @@ Advanced Tutorials :doc:`triggers-use-cases` Set up multi-trigger monitoring scenarios for OTA updates, thermal protection, and fleet diagnostics. + +:doc:`multi-instance` + Federate multiple gateway instances into a single API with peer aggregation. diff --git a/docs/tutorials/manifest-discovery.rst b/docs/tutorials/manifest-discovery.rst index b98dd7960..862ac8bf3 100644 --- a/docs/tutorials/manifest-discovery.rst +++ b/docs/tutorials/manifest-discovery.rst @@ -429,10 +429,10 @@ robot itself is the top-level component, with subcomponents for hardware modules node_name: controller_server namespace: / -For runtime-only mode, set ``discovery.runtime.create_synthetic_areas: false`` -to prevent automatic area creation from namespaces. See -:ref:`manifest-flat-entity-tree` in the manifest schema reference and -``config/examples/flat_robot_manifest.yaml`` for a complete example. +For runtime-only mode, Areas are never created from namespaces - they come +from manifest only. See :ref:`manifest-flat-entity-tree` in the manifest +schema reference and ``config/examples/flat_robot_manifest.yaml`` for a +complete example. **When to use each pattern:** @@ -484,17 +484,20 @@ this behavior: discovery: merge_pipeline: gap_fill: - allow_heuristic_areas: true # Create areas from namespaces - allow_heuristic_components: true # Create synthetic components allow_heuristic_apps: true # Create apps from unbound nodes allow_heuristic_functions: false # Don't create heuristic functions # namespace_blacklist: ["/rosout"] # Exclude specific namespaces # namespace_whitelist: [] # If set, only allow these namespaces +.. note:: + + Areas and Components are never created by runtime discovery. Areas come + from manifest only. Components come from ``HostInfoProvider`` or manifest. + When all ``allow_heuristic_*`` options are ``false``, only manifest-declared entities appear. Runtime nodes are still discovered for linking, but no -heuristic entities (areas, components, apps, functions) are created from -unmatched namespaces or nodes. +heuristic entities (apps, functions) are created from unmatched namespaces +or nodes. See :doc:`/config/discovery-options` for the full merge pipeline reference. diff --git a/docs/tutorials/multi-instance.rst b/docs/tutorials/multi-instance.rst new file mode 100644 index 000000000..2e78c5042 --- /dev/null +++ b/docs/tutorials/multi-instance.rst @@ -0,0 +1,364 @@ +Multi-Instance Aggregation +========================== + +This tutorial walks through setting up multiple ros2_medkit_gateway instances +and aggregating their entity trees into a single unified API. + +.. contents:: Table of Contents + :local: + :depth: 2 + +Prerequisites +------------- + +- ros2_medkit built and installed (``colcon build && source install/setup.bash``) +- Two or more terminals available +- Familiarity with the :doc:`../getting_started` guide + +What You Will Learn +------------------- + +- How to run two gateways on different ports +- How to configure static peer connections +- How to enable mDNS auto-discovery +- What the merged API looks like +- How to handle peer failures +- How to set up chain topologies + +Step 1: Start Two Gateways +-------------------------- + +Open two terminals. In each, source the ROS 2 workspace: + +.. code-block:: bash + + source /opt/ros/jazzy/setup.bash + source install/setup.bash + +**Terminal 1 - Gateway A (port 8080):** + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p server.port:=8080 \ + -r __node:=gateway_a \ + -r __ns:=/subsystem_a + +**Terminal 2 - Gateway B (port 8081):** + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + -p server.port:=8081 \ + -r __node:=gateway_b \ + -r __ns:=/subsystem_b + +Verify both gateways are healthy: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/health + curl http://localhost:8081/api/v1/health + +Each gateway should report ``{"status": "healthy"}``. + +Step 2: Configure Static Peers +------------------------------ + +To make Gateway A aggregate entities from Gateway B, create a config file: + +.. code-block:: yaml + :caption: gateway_a_params.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: true + peer_urls: ["http://localhost:8081"] + peer_names: ["subsystem_b"] + +Restart Gateway A with the config: + +.. code-block:: bash + + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + --params-file gateway_a_params.yaml \ + -r __node:=gateway_a + +Now Gateway A merges entities from both itself and Gateway B. + +Step 3: Explore the Merged API +------------------------------ + +**List all components (merged from both gateways):** + +.. code-block:: bash + + curl -s http://localhost:8080/api/v1/components | jq + +The response includes components from both gateways. Remote-only Components +have ``"source": "peer:subsystem_b"`` in their metadata. + +If both gateways have a component with the same ID (e.g., both hosts are named +``robot``), they are **merged by ID** into a single entity (tags and metadata +are combined). Remote-only Components appear as separate entries: + +.. code-block:: json + + { + "items": [ + {"id": "robot", "source": "runtime"}, + {"id": "arm_controller", "source": "peer:subsystem_b"} + ] + } + +**List all areas (merged by ID):** + +.. code-block:: bash + + curl -s http://localhost:8080/api/v1/areas | jq + +Areas are merged by ID - if both gateways discover a ``root`` area, only one +``root`` appears in the response. + +**Access data from a remote entity:** + +.. code-block:: bash + + # If subsystem_b has a component "arm_controller" + curl -s http://localhost:8080/api/v1/components/arm_controller/data | jq + +The request is transparently forwarded to Gateway B. The client does not need +to know which gateway owns the entity. + +**List all functions:** + +.. code-block:: bash + + curl -s http://localhost:8080/api/v1/functions | jq + +Functions are merged by ID with combined ``hosts`` lists. A ``navigation`` +function that exists on both gateways appears once, listing hosts from both. + +Step 4: Enable mDNS Auto-Discovery +----------------------------------- + +Instead of listing peers statically, use mDNS to discover gateways +automatically on the local network. + +**Gateway A config with mDNS:** + +.. code-block:: yaml + :caption: gateway_a_mdns.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: true + announce: true + discover: true + mdns_name: "gateway_a" + +**Gateway B config with mDNS:** + +.. code-block:: yaml + :caption: gateway_b_mdns.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8081 + aggregation: + enabled: true + announce: true + discover: true + mdns_name: "gateway_b" + +.. note:: + + ``mdns_name`` must be set explicitly when running multiple gateways on + the same host. Without it, all instances share the same hostname and + filter each other out as "self". On separate hosts or containers with + distinct hostnames, this parameter can be omitted. + +Start both gateways with their configs: + +.. code-block:: bash + + # Terminal 1 + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + --params-file gateway_a_mdns.yaml -r __node:=gateway_a + + # Terminal 2 + ros2 run ros2_medkit_gateway gateway_node --ros-args \ + --params-file gateway_b_mdns.yaml -r __node:=gateway_b + +After a few seconds, each gateway discovers the other via mDNS. Check the +health endpoint to see discovered peers: + +.. code-block:: bash + + curl -s http://localhost:8080/api/v1/health | jq '.peers' + +.. note:: + + mDNS requires multicast network support. If using Docker, ensure containers + share a network that supports multicast, or use host networking. For + bridge-networked containers, use static peers instead. + +Step 5: Handle Peer Failures +----------------------------- + +When a peer goes down, the gateway handles it gracefully: + +1. **Health checks detect failure**: The cache refresh cycle + (default: 10 seconds) runs ``check_all_health()`` and detects the peer is + unreachable, marking it unhealthy. + +2. **Entity collection endpoints serve cached data**: Endpoints like + ``GET /api/v1/components`` continue to return the last cached entity set. + Remote entities from the unhealthy peer are dropped from the cache on the + next refresh cycle. + +3. **Fault fan-out returns partial results**: ``GET /api/v1/faults`` performs + real-time fan-out and includes ``x-medkit.partial: true`` and + ``x-medkit.failed_peers`` in the response when some peers are unreachable. + +4. **Entity-specific requests return 502**: If a request targets a remote + entity whose peer is down, the gateway returns ``502 Bad Gateway``. + +Test this by stopping Gateway B and querying Gateway A: + +.. code-block:: bash + + # Stop Gateway B (Ctrl+C in Terminal 2) + + # Wait for cache refresh interval, then: + curl -s http://localhost:8080/api/v1/components | jq + # Returns only local components (remote entities dropped from cache) + + # Faults show partial results: + curl -s http://localhost:8080/api/v1/faults | jq '.["x-medkit"].partial' + # Returns true + + # Try accessing a remote entity: + curl -s http://localhost:8080/api/v1/apps/subsystem_b__some_node/data + # Returns 502 Bad Gateway + +When Gateway B comes back online, it is automatically re-included after the +next successful health check. + +Step 6: Chain Topology +---------------------- + +For hierarchical systems, gateways can be chained. Gateway A aggregates from +B, which aggregates from C: + +.. code-block:: yaml + :caption: gateway_a_chain.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8080 + aggregation: + enabled: true + peer_urls: ["http://localhost:8081"] + peer_names: ["mid_level"] + +.. code-block:: yaml + :caption: gateway_b_chain.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8081 + aggregation: + enabled: true + peer_urls: ["http://localhost:8082"] + peer_names: ["leaf_system"] + +.. code-block:: yaml + :caption: gateway_c_chain.yaml + + ros2_medkit_gateway: + ros__parameters: + server: + port: 8082 + aggregation: + enabled: false + +Start all three gateways, then query the top-level: + +.. code-block:: bash + + curl -s http://localhost:8080/api/v1/components | jq + +Gateway A returns components from all three levels. Requests for entities on +Gateway C are forwarded through B to C. + +Summary +------- + +- **Static peers**: List known gateways in ``aggregation.peer_urls`` and + ``aggregation.peer_names`` for deterministic connections. +- **mDNS discovery**: Set ``aggregation.announce`` and ``aggregation.discover`` + to ``true`` for zero-configuration peer discovery. +- **Entity merging**: Areas, Functions, and Components merge by ID. Apps get + peer-name prefixes on collision. +- **Transparent forwarding**: Requests for remote entities are forwarded to the + owning peer. Clients interact with a single API endpoint. +- **Graceful degradation**: Unhealthy peers are excluded from fan-out. Partial + results are clearly marked. + +Troubleshooting +--------------- + +**mDNS socket bind failure (port 5353)** + +mDNS announcement requires binding to UDP port 5353, which is a privileged +port (below 1024). If the gateway logs an error like:: + + mDNS: Failed to open mDNS announce socket on port 5353. + +This means the process does not have permission to bind to port 5353. Solutions +(choose one): + +1. **Grant the capability to the binary** (recommended for production): + + .. code-block:: bash + + sudo setcap cap_net_bind_service=+ep $(which gateway_node) + +2. **Run as root** (not recommended for production): + + .. code-block:: bash + + sudo ros2 run ros2_medkit_gateway gateway_node --ros-args ... + +3. **Use Docker with host networking**: + + .. code-block:: bash + + docker run --net=host ... + +4. **Fall back to static peers**: If mDNS is not viable in your environment, + disable ``aggregation.announce`` and ``aggregation.discover``, and configure + peers explicitly with ``aggregation.peer_urls`` and ``aggregation.peer_names``. + +.. note:: + + On systems where another mDNS responder is already running (e.g., Avahi, + systemd-resolved), port 5353 may already be in use. Either stop the + existing responder or use static peers. + +Next Steps +---------- + +- :doc:`../config/aggregation` - Full configuration reference +- :doc:`docker` - Deploy aggregated gateways in Docker containers +- :doc:`authentication` - Secure peer-to-peer communication with JWT diff --git a/src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake b/src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake index 9a1eba5d8..e252d3c7b 100644 --- a/src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake +++ b/src/ros2_medkit_cmake/cmake/ROS2MedkitTestDomain.cmake @@ -27,6 +27,7 @@ # ros2_medkit_graph_provider: 120 - 129 (10 slots) # ros2_medkit_linux_introspection: 130 - 139 (10 slots) # ros2_medkit_integration_tests: 140 - 229 (90 slots) +# multi-domain tests (secondary): 230 - 232 (3 slots, via get_test_domain_id) # # To add a new package: pick the next free range and update this comment. diff --git a/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp b/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp index d3e8ce175..09d0649f2 100644 --- a/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp +++ b/src/ros2_medkit_fault_manager/test/test_fault_manager.cpp @@ -749,6 +749,10 @@ class FaultEventPublishingTest : public ::testing::Test { ASSERT_TRUE(clear_fault_client_->wait_for_service(std::chrono::seconds(5))); ASSERT_TRUE(get_fault_client_->wait_for_service(std::chrono::seconds(5))); ASSERT_TRUE(list_faults_for_entity_client_->wait_for_service(std::chrono::seconds(5))); + + // Drain any stale DDS messages from previous tests (same topic, new subscription) + spin_for(std::chrono::milliseconds(50)); + received_events_.clear(); } void TearDown() override { @@ -771,6 +775,37 @@ class FaultEventPublishingTest : public ::testing::Test { } } + /// Spin until a predicate becomes true or timeout expires. + /// More robust than fixed spin_for() under CPU contention. + bool spin_until(const std::function & predicate, + std::chrono::milliseconds timeout = std::chrono::milliseconds(2000)) { + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + rclcpp::spin_some(fault_manager_); + rclcpp::spin_some(test_node_); + if (predicate()) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return predicate(); + } + + /// Spin until a future is ready, with 2s timeout. Robust under CPU contention. + template + bool spin_until_future_ready(FutureT & future, std::chrono::milliseconds timeout = std::chrono::milliseconds(2000)) { + auto start = std::chrono::steady_clock::now(); + while (std::chrono::steady_clock::now() - start < timeout) { + rclcpp::spin_some(fault_manager_); + rclcpp::spin_some(test_node_); + if (future.wait_for(std::chrono::milliseconds(0)) == std::future_status::ready) { + return true; + } + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + return future.wait_for(std::chrono::milliseconds(0)) == std::future_status::ready; + } + bool call_report_fault(const std::string & fault_code, uint8_t severity, const std::string & source_id) { auto request = std::make_shared(); request->fault_code = fault_code; @@ -780,8 +815,7 @@ class FaultEventPublishingTest : public ::testing::Test { request->source_id = source_id; auto future = report_fault_client_->async_send_request(request); - spin_for(std::chrono::milliseconds(100)); - if (future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + if (!spin_until_future_ready(future)) { return false; } return future.get()->accepted; @@ -792,8 +826,7 @@ class FaultEventPublishingTest : public ::testing::Test { request->fault_code = fault_code; auto future = clear_fault_client_->async_send_request(request); - spin_for(std::chrono::milliseconds(100)); - if (future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + if (!spin_until_future_ready(future)) { return false; } return future.get()->success; @@ -804,8 +837,7 @@ class FaultEventPublishingTest : public ::testing::Test { request->fault_code = fault_code; auto future = get_fault_client_->async_send_request(request); - spin_for(std::chrono::milliseconds(100)); - if (future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + if (!spin_until_future_ready(future)) { return std::nullopt; } return *future.get(); @@ -816,8 +848,7 @@ class FaultEventPublishingTest : public ::testing::Test { request->entity_id = entity_id; auto future = list_faults_for_entity_client_->async_send_request(request); - spin_for(std::chrono::milliseconds(100)); - if (future.wait_for(std::chrono::seconds(0)) != std::future_status::ready) { + if (!spin_until_future_ready(future)) { return std::nullopt; } return *future.get(); @@ -837,8 +868,10 @@ TEST_F(FaultEventPublishingTest, NewFaultPublishesConfirmedEvent) { // Report a new fault - with threshold=-1, it should immediately confirm ASSERT_TRUE(call_report_fault("TEST_FAULT_1", Fault::SEVERITY_ERROR, "/test_node")); - // Allow time for event to be received - spin_for(std::chrono::milliseconds(100)); + // Wait for event to arrive (polling, robust under CPU contention) + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Verify EVENT_CONFIRMED was published ASSERT_EQ(received_events_.size(), 1u); @@ -851,14 +884,18 @@ TEST_F(FaultEventPublishingTest, NewFaultPublishesConfirmedEvent) { TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) { // Report a new fault first ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_WARN, "/test_node1")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Clear received events received_events_.clear(); // Report same fault again - should trigger EVENT_UPDATED ASSERT_TRUE(call_report_fault("TEST_FAULT_2", Fault::SEVERITY_ERROR, "/test_node2")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Verify EVENT_UPDATED was published ASSERT_EQ(received_events_.size(), 1u); @@ -870,14 +907,18 @@ TEST_F(FaultEventPublishingTest, UpdateExistingFaultPublishesUpdatedEvent) { TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) { // Report a fault first ASSERT_TRUE(call_report_fault("TEST_FAULT_3", Fault::SEVERITY_ERROR, "/test_node")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Clear received events received_events_.clear(); // Clear the fault ASSERT_TRUE(call_clear_fault("TEST_FAULT_3")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Verify EVENT_CLEARED was published ASSERT_EQ(received_events_.size(), 1u); @@ -889,6 +930,7 @@ TEST_F(FaultEventPublishingTest, ClearFaultPublishesClearedEvent) { TEST_F(FaultEventPublishingTest, ClearNonExistentFaultNoEvent) { // Clear non-existent fault - should not publish event ASSERT_FALSE(call_clear_fault("NON_EXISTENT_FAULT")); + // Brief spin to confirm no event arrives (negative test - keep short) spin_for(std::chrono::milliseconds(100)); // Verify no events published @@ -899,7 +941,9 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) { auto before = fault_manager_->now(); ASSERT_TRUE(call_report_fault("TEST_FAULT_4", Fault::SEVERITY_WARN, "/test_node")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); auto after = fault_manager_->now(); @@ -913,7 +957,9 @@ TEST_F(FaultEventPublishingTest, EventContainsCorrectTimestamp) { TEST_F(FaultEventPublishingTest, EventContainsFullFaultData) { ASSERT_TRUE(call_report_fault("FULL_DATA_TEST", Fault::SEVERITY_CRITICAL, "/sensor/temperature")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); ASSERT_EQ(received_events_.size(), 1u); const auto & fault = received_events_[0].fault; @@ -934,7 +980,9 @@ TEST_F(FaultEventPublishingTest, TimestampUsesWallClockNotSimTime) { auto wall_before_ns = std::chrono::duration_cast(wall_before.time_since_epoch()).count(); ASSERT_TRUE(call_report_fault("WALL_CLOCK_TEST", Fault::SEVERITY_WARN, "/test_node")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); auto wall_after = std::chrono::system_clock::now(); auto wall_after_ns = std::chrono::duration_cast(wall_after.time_since_epoch()).count(); @@ -960,7 +1008,9 @@ TEST_F(FaultEventPublishingTest, TimestampUsesWallClockNotSimTime) { TEST_F(FaultEventPublishingTest, GetFaultReturnsExpectedFault) { // Report a fault first ASSERT_TRUE(call_report_fault("GET_FAULT_TEST", Fault::SEVERITY_ERROR, "/test_node")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Get fault via service auto response = call_get_fault("GET_FAULT_TEST"); @@ -982,7 +1032,9 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsNotFoundForMissingFault) { TEST_F(FaultEventPublishingTest, GetFaultReturnsEnvironmentData) { // Report a fault ASSERT_TRUE(call_report_fault("ENV_DATA_TEST", Fault::SEVERITY_WARN, "/sensor/temp")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); auto response = call_get_fault("ENV_DATA_TEST"); ASSERT_TRUE(response.has_value()); @@ -1000,9 +1052,13 @@ TEST_F(FaultEventPublishingTest, GetFaultReturnsEnvironmentData) { TEST_F(FaultEventPublishingTest, GetFaultReturnsExtendedDataRecords) { // Report fault twice to have first and last occurrence timestamps differ ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node1")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); ASSERT_TRUE(call_report_fault("EDR_TEST", Fault::SEVERITY_ERROR, "/node2")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 2; + })); auto response = call_get_fault("EDR_TEST"); ASSERT_TRUE(response.has_value()); @@ -1020,7 +1076,9 @@ TEST_F(FaultEventPublishingTest, ListFaultsForEntitySuccess) { ASSERT_TRUE(call_report_fault("MOTOR_FAULT", Fault::SEVERITY_ERROR, "/powertrain/motor_controller")); ASSERT_TRUE(call_report_fault("SENSOR_FAULT", Fault::SEVERITY_WARN, "/powertrain/motor_controller")); ASSERT_TRUE(call_report_fault("BRAKE_FAULT", Fault::SEVERITY_ERROR, "/chassis/brake_system")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 3; + })); // Query faults for motor_controller entity auto response = call_list_faults_for_entity("motor_controller"); @@ -1042,7 +1100,9 @@ TEST_F(FaultEventPublishingTest, ListFaultsForEntitySuccess) { TEST_F(FaultEventPublishingTest, ListFaultsForEntityEmptyResult) { // Report faults from a different entity ASSERT_TRUE(call_report_fault("SOME_FAULT", Fault::SEVERITY_ERROR, "/some/other_entity")); - spin_for(std::chrono::milliseconds(100)); + ASSERT_TRUE(spin_until([this]() { + return received_events_.size() >= 1; + })); // Query faults for non-existent entity auto response = call_list_faults_for_entity("motor_controller"); diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index 146183e16..4d64b03a3 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -77,6 +77,10 @@ target_include_directories(jwt_cpp_iface SYSTEM INTERFACE ) add_library(jwt-cpp::jwt-cpp ALIAS jwt_cpp_iface) +# mjansson/mdns (Public Domain) - https://github.com/mjansson/mdns +# Header-only C library for mDNS/DNS-SD, used for peer gateway discovery +include_directories(SYSTEM src/vendored/mdns) + # Include directories include_directories(include) @@ -111,6 +115,8 @@ add_library(gateway_lib STATIC src/discovery/layers/manifest_layer.cpp src/discovery/layers/runtime_layer.cpp src/discovery/layers/plugin_layer.cpp + # Host info provider (default component from host system) + src/discovery/host_info_provider.cpp # Discovery models (with .cpp serialization) src/discovery/models/app.cpp src/discovery/models/function.cpp @@ -184,6 +190,12 @@ add_library(gateway_lib STATIC src/trigger_topic_subscriber.cpp # Trigger HTTP handlers src/http/handlers/trigger_handlers.cpp + # Aggregation module + src/aggregation/peer_client.cpp + src/aggregation/entity_merger.cpp + src/aggregation/aggregation_manager.cpp + src/aggregation/mdns_discovery.cpp + src/aggregation/sse_stream_proxy.cpp ) medkit_target_dependencies(gateway_lib @@ -305,6 +317,7 @@ if(BUILD_TESTING) "src/vendored/jwt_cpp/include/jwt-cpp/traits/nlohmann-json/traits.h" "src/vendored/jwt_cpp/include/picojson/picojson.h" "src/vendored/tl_expected/include/tl/expected.hpp" + "src/vendored/mdns/mdns.h" ) ament_copyright(EXCLUDE ${VENDORED_FILES}) @@ -316,6 +329,7 @@ if(BUILD_TESTING) "test/*.cpp" "test/*.h" "test/*.hpp" ) list(FILTER _format_files EXCLUDE REGEX ".*/vendored/.*") + list(FILTER _format_files EXCLUDE REGEX ".*/vendored/.*") ament_clang_format(${_format_files} CONFIG_FILE "${ament_cmake_clang_format_CONFIG_FILE}" ) @@ -366,6 +380,11 @@ if(BUILD_TESTING) medkit_target_dependencies(test_discovery_manager std_msgs) medkit_set_test_domain(test_discovery_manager) + # Add RuntimeDiscoveryStrategy tests + ament_add_gtest(test_runtime_discovery test/test_runtime_discovery.cpp) + target_link_libraries(test_runtime_discovery gateway_lib) + medkit_set_test_domain(test_runtime_discovery) + # Add TLS configuration tests ament_add_gtest(test_tls_config test/test_tls_config.cpp) target_link_libraries(test_tls_config gateway_lib) @@ -380,6 +399,10 @@ if(BUILD_TESTING) ament_add_gtest(test_discovery_models test/test_discovery_models.cpp) target_link_libraries(test_discovery_models gateway_lib) + # Add host info provider tests + ament_add_gtest(test_host_info_provider test/test_host_info_provider.cpp) + target_link_libraries(test_host_info_provider gateway_lib) + # Add discovery handlers tests ament_add_gtest(test_discovery_handlers test/test_discovery_handlers.cpp) target_link_libraries(test_discovery_handlers gateway_lib) @@ -412,6 +435,7 @@ if(BUILD_TESTING) # Add handler context tests ament_add_gtest(test_handler_context test/test_handler_context.cpp) target_link_libraries(test_handler_context gateway_lib) + medkit_set_test_domain(test_handler_context) # Add x-medkit extension tests ament_add_gtest(test_x_medkit test/test_x_medkit.cpp) @@ -435,6 +459,10 @@ if(BUILD_TESTING) ament_add_gtest(test_entity_resource_model test/test_entity_resource_model.cpp) target_link_libraries(test_entity_resource_model gateway_lib) + # Function/Area resource collection aggregation tests + ament_add_gtest(test_function_resource_collections test/test_function_resource_collections.cpp) + target_link_libraries(test_function_resource_collections gateway_lib) + # Add entity path utils tests ament_add_gtest(test_entity_path_utils test/test_entity_path_utils.cpp) target_link_libraries(test_entity_path_utils gateway_lib) @@ -630,6 +658,30 @@ if(BUILD_TESTING) ament_add_gtest(test_trigger_handlers test/test_trigger_handlers.cpp) target_link_libraries(test_trigger_handlers gateway_lib) + # Peer client tests (aggregation module) + ament_add_gtest(test_peer_client test/test_peer_client.cpp) + target_link_libraries(test_peer_client gateway_lib) + + # Entity merger tests (aggregation module) + ament_add_gtest(test_entity_merger test/test_entity_merger.cpp) + target_link_libraries(test_entity_merger gateway_lib) + + # Aggregation manager tests (aggregation module) + ament_add_gtest(test_aggregation_manager test/test_aggregation_manager.cpp) + target_link_libraries(test_aggregation_manager gateway_lib) + + # Stream proxy tests (SSE parsing, aggregation module) + ament_add_gtest(test_stream_proxy test/test_stream_proxy.cpp) + target_link_libraries(test_stream_proxy gateway_lib) + + # mDNS discovery tests (aggregation module) + ament_add_gtest(test_mdns_discovery test/test_mdns_discovery.cpp) + target_link_libraries(test_mdns_discovery gateway_lib) + + # Network utility tests (URL parsing, local address collection) + ament_add_gtest(test_network_utils test/test_network_utils.cpp) + target_link_libraries(test_network_utils gateway_lib) + # Apply coverage flags to test targets if(ENABLE_COVERAGE) set(_test_targets @@ -640,9 +692,11 @@ if(BUILD_TESTING) test_configuration_manager test_native_topic_sampler test_discovery_manager + test_runtime_discovery test_tls_config test_fault_manager test_discovery_models + test_host_info_provider test_discovery_handlers test_manifest_parser test_manifest_validator @@ -689,6 +743,12 @@ if(BUILD_TESTING) test_trigger_store test_trigger_manager test_trigger_handlers + test_peer_client + test_entity_merger + test_aggregation_manager + test_stream_proxy + test_mdns_discovery + test_network_utils ) foreach(_target ${_test_targets}) target_compile_options(${_target} PRIVATE --coverage -O0 -g) diff --git a/src/ros2_medkit_gateway/README.md b/src/ros2_medkit_gateway/README.md index b3e8c4ce2..d382fb9ed 100644 --- a/src/ros2_medkit_gateway/README.md +++ b/src/ros2_medkit_gateway/README.md @@ -4,11 +4,11 @@ HTTP gateway node for the ros2_medkit diagnostics system. ## Overview -The ROS 2 Medkit Gateway exposes ROS 2 system information and data through a RESTful HTTP API. It automatically discovers nodes in the ROS 2 system, organizes them into areas based on their namespaces, and provides endpoints to query and interact with them. +The ROS 2 Medkit Gateway exposes ROS 2 system information and data through a RESTful HTTP API. It automatically discovers nodes in the ROS 2 system, organizes them into a SOVD-aligned entity hierarchy (Areas, Components, Apps, Functions), and provides endpoints to query and interact with them. **Key Features:** - **Auto-discovery**: Automatically detects ROS 2 nodes and topics -- **Area-based organization**: Groups nodes by namespace (e.g., `/powertrain`, `/chassis`, `/body`) +- **SOVD entity model**: Areas, Components (host-level), Apps (ROS 2 nodes), and Functions (namespace-based logical grouping) - **REST API**: Standard HTTP/JSON interface - **Real-time updates**: Configurable cache refresh for up-to-date system state - **Bulk Data Management**: Upload, download, list, and delete bulk data files (calibration, firmware, etc.) @@ -33,7 +33,12 @@ All endpoints are prefixed with `/api/v1` for API versioning. - `GET /api/v1/components/{component_id}/hosts` - List apps hosted on a component - `GET /api/v1/components/{component_id}/depends-on` - List component dependencies - `GET /api/v1/areas/{area_id}/components` - List components within a specific area +- `GET /api/v1/apps` - List all discovered apps +- `GET /api/v1/apps/{app_id}` - Get app capabilities - `GET /api/v1/apps/{app_id}/is-located-on` - Get the component hosting this app +- `GET /api/v1/functions` - List all discovered functions +- `GET /api/v1/functions/{function_id}` - Get function capabilities +- `GET /api/v1/functions/{function_id}/hosts` - List apps grouped by this function ### Component Data Endpoints @@ -131,18 +136,20 @@ curl http://localhost:8080/api/v1/areas **Response:** ```json -[ - { - "id": "powertrain", - "namespace": "/powertrain", - "type": "Area" - }, - { - "id": "chassis", - "namespace": "/chassis", - "type": "Area" - } -] +{ + "items": [ + { + "id": "powertrain", + "name": "powertrain", + "href": "/api/v1/areas/powertrain" + }, + { + "id": "chassis", + "name": "chassis", + "href": "/api/v1/areas/chassis" + } + ] +} ``` #### GET /api/v1/components @@ -156,33 +163,21 @@ curl http://localhost:8080/api/v1/components **Response:** ```json -[ - { - "id": "temp_sensor", - "namespace": "/powertrain/engine", - "fqn": "/powertrain/engine/temp_sensor", - "type": "Component", - "area": "powertrain", - "source": "node" - }, - { - "id": "carter1", - "namespace": "/carter1", - "fqn": "/carter1", - "type": "Component", - "area": "carter1", - "source": "topic" - } -] +{ + "items": [ + { + "id": "my_hostname", + "name": "my_hostname", + "href": "/api/v1/components/my_hostname" + } + ] +} ``` **Response Fields:** -- `id` - Component name (node name or namespace for topic-based) -- `namespace` - ROS 2 namespace where the component is running -- `fqn` - Fully qualified name (namespace + node name) -- `type` - Always "Component" -- `source` - Discovery source: `"node"` (standard ROS 2 node) or `"topic"` (discovered from topic namespaces) -- `area` - Parent area this component belongs to +- `id` - Component identifier (hostname in runtime mode, or manifest-defined) +- `name` - Human-readable name +- `href` - URI to the component capabilities endpoint #### GET /api/v1/areas/{area_id}/components @@ -195,24 +190,20 @@ curl http://localhost:8080/api/v1/areas/powertrain/components **Response (200 OK):** ```json -[ - { - "id": "temp_sensor", - "namespace": "/powertrain/engine", - "fqn": "/powertrain/engine/temp_sensor", - "type": "Component", - "area": "powertrain", - "source": "node" - }, - { - "id": "rpm_sensor", - "namespace": "/powertrain/engine", - "fqn": "/powertrain/engine/rpm_sensor", - "type": "Component", - "area": "powertrain", - "source": "node" - } -] +{ + "items": [ + { + "id": "temp_sensor", + "name": "temp_sensor", + "href": "/api/v1/components/temp_sensor" + }, + { + "id": "rpm_sensor", + "name": "rpm_sensor", + "href": "/api/v1/components/rpm_sensor" + } + ] +} ``` **Example (Error - Area Not Found):** @@ -249,16 +240,18 @@ curl http://localhost:8080/api/v1/components/temp_sensor/data **Response (200 OK):** ```json -[ - { - "topic": "/powertrain/engine/temperature", - "timestamp": 1732377600000000000, - "data": { - "temperature": 85.5, - "variance": 0.0 +{ + "items": [ + { + "topic": "/powertrain/engine/temperature", + "timestamp": 1732377600000000000, + "data": { + "temperature": 85.5, + "variance": 0.0 + } } - } -] + ] +} ``` **Example (Error - Component Not Found):** @@ -393,18 +386,20 @@ curl http://localhost:8080/api/v1/components/calibration/operations **Response (200 OK):** ```json -[ - { - "name": "calibrate", - "path": "/powertrain/engine/calibrate", - "type": "std_srvs/srv/Trigger", - "kind": "service", - "type_info": { - "schema": "...", - "default_value": "..." +{ + "items": [ + { + "name": "calibrate", + "path": "/powertrain/engine/calibrate", + "type": "std_srvs/srv/Trigger", + "kind": "service", + "type_info": { + "schema": "...", + "default_value": "..." + } } - } -] + ] +} ``` #### POST /api/v1/components/{component_id}/operations/{operation_id}/executions @@ -1460,15 +1455,21 @@ In addition to standard ROS 2 node discovery, the gateway supports **topic-based - `/parameter_events`, `/rosout`, `/clock` - Note: `/tf` and `/tf_static` are NOT filtered (useful for diagnostics) -### Area Organization +### Entity Organization -The gateway organizes nodes into "areas" based on their namespace: +In runtime discovery mode, the gateway maps the ROS 2 graph to the SOVD entity model: + +- **Component**: A single host-derived Component is created from `HostInfoProvider` (hostname, OS, architecture). All Apps belong to this Component. +- **App**: Each discovered ROS 2 node becomes an App entity. +- **Function**: The first namespace segment creates a Function entity that groups all Apps under that namespace (e.g., `/powertrain/engine/temp_sensor` and `/powertrain/engine/rpm_sensor` both belong to Function `powertrain`). +- **Area**: Areas are only created from manifest definitions. They are never auto-generated in runtime mode. Use hybrid or manifest-only mode to organize entities into Areas. ``` -/powertrain/engine/temp_sensor → Area: powertrain, Component: temp_sensor -/chassis/brakes/pressure_sensor → Area: chassis, Component: pressure_sensor -/body/lights/controller → Area: body, Component: controller -/standalone_node → Area: root, Component: standalone_node +Runtime mode mapping: +/powertrain/engine/temp_sensor -> Component: , Function: powertrain, App: temp_sensor +/chassis/brakes/pressure_sensor -> Component: , Function: chassis, App: pressure_sensor +/body/lights/controller -> Component: , Function: body, App: controller +/standalone_node -> Component: , App: standalone_node ``` ## Demo Nodes @@ -1520,6 +1521,40 @@ We provide a Postman collection for easy API testing: See [postman/README.md](postman/README.md) for detailed instructions. +## Multi-Instance Aggregation + +Federate multiple gateway instances into a single unified API. A primary gateway merges entities from peer gateways and transparently forwards requests for remote entities. + +**Quick Start:** + +```yaml +# gateway_params.yaml +ros2_medkit_gateway: + ros__parameters: + aggregation: + enabled: true + peer_urls: ["http://localhost:8081"] + peer_names: ["subsystem_b"] +``` + +```bash +# Start primary gateway with aggregation +ros2 run ros2_medkit_gateway gateway_node --ros-args \ + --params-file gateway_params.yaml + +# Query merged entity tree +curl http://localhost:8080/api/v1/components | jq +``` + +**Key features:** +- **Static peers**: List known gateways in config +- **mDNS auto-discovery**: Zero-configuration peer discovery on local network +- **Type-aware merging**: Areas, Functions, and Components merge by ID; Apps get peer-name prefixes on collision +- **Transparent forwarding**: Requests for remote entities forwarded to owning peer +- **Graceful degradation**: Unhealthy peers excluded, partial results clearly marked + +See the [documentation](https://selfpatch.github.io/ros2_medkit/) for detailed configuration reference and tutorials. + ## Testing ### Run Integration Tests diff --git a/src/ros2_medkit_gateway/config/gateway_params.yaml b/src/ros2_medkit_gateway/config/gateway_params.yaml index 3016c67f2..1accbccdb 100644 --- a/src/ros2_medkit_gateway/config/gateway_params.yaml +++ b/src/ros2_medkit_gateway/config/gateway_params.yaml @@ -177,53 +177,36 @@ ros2_medkit_gateway: # Runtime (heuristic) discovery options # These control how nodes are mapped to SOVD entities in runtime mode runtime: - # Create synthetic Area entities from ROS 2 namespaces - # When true (default), namespaces become Areas - # When false, no Areas are created - flat component tree - # Useful for simple robots without area hierarchy - create_synthetic_areas: true - - # Create synthetic Component entities that group Apps - # When true, Components are synthetic groupings (by namespace) - # When false, each node is a Component - # Default: true - create_synthetic_components: true - - # How to group nodes into synthetic components - # Options: - # - "none": Each node = 1 component (default when synthetic off) - # - "namespace": Group by first namespace segment (area) - # Note: "process" grouping is planned for future (requires R&D) - grouping_strategy: "namespace" - - # Naming pattern for synthetic components - # Placeholders: {area} - # Examples: "{area}", "{area}_group", "component_{area}" - # When create_synthetic_areas is false, {area} still resolves to - # the namespace segment used as the component grouping key. - synthetic_component_name_pattern: "{area}" - - # Policy for namespaces with topics but no ROS 2 nodes - # Common with Isaac Sim, external bridges, or dead/orphaned topics - # Options: - # - "ignore": Don't create any entity for topic-only namespaces - # - "create_component": Create component with source="topic" (default) - # - "create_area_only": Only create the area, no component - topic_only_policy: "create_component" - - # Minimum number of topics to create a topic-based component - # Only applies when topic_only_policy is "create_component" - # Namespaces with fewer topics are skipped - # Default: 1 (create component for any namespace with topics) - min_topics_for_component: 1 + # Auto-detect local host system as the default Component + # When true (default), a single host-level Component is created + # from system info (hostname, OS, architecture) instead of + # synthetic per-namespace Components. All discovered Apps are + # linked to this Component via the is-located-on relationship. + # Only used in runtime_only mode. + default_component: + enabled: true + + # Filter out ROS 2 internal nodes (underscore-prefixed names) + # When true (default), nodes like _ros2cli_*, _param_client_node + # are excluded from the entity tree. Applies to both local and + # peer-discovered Apps. + filter_internal_nodes: true + + # Create Function entities from ROS 2 namespace grouping + # When true (default), namespaces are mapped to Function entities + # Each Function hosts the Apps in that namespace + # This is the SOVD-correct mapping: namespaces represent + # functional grouping, not deployment topology + create_functions_from_namespaces: true # Merge pipeline configuration (only used in hybrid mode) # Controls how manifest, runtime, and plugin layers merge entities merge_pipeline: # Gap-fill: what runtime discovery can create when manifest is incomplete + # Note: Areas and Components are never created by runtime discovery. + # Areas come from manifest only. Components come from HostInfoProvider + # or manifest. gap_fill: - allow_heuristic_areas: true - allow_heuristic_components: true allow_heuristic_apps: true allow_heuristic_functions: false # namespace_blacklist: ["/rosout"] @@ -481,3 +464,67 @@ ros2_medkit_gateway: apps: lock_required_scopes: [""] # empty = no lock required by default breakable: true + + # Aggregation Configuration + # Federation of multiple gateway instances via peer discovery and entity merging. + # When enabled, this gateway merges entities from peer gateways into its cache + # and transparently forwards requests for remote entities to the owning peer. + aggregation: + # Enable/disable peer aggregation + # Default: false + enabled: false + + # HTTP timeout for peer requests (health checks, entity fetching, forwarding) + # in milliseconds + # Default: 2000 + timeout_ms: 2000 + + # Announce this gateway's presence via mDNS + # Default: false (opt-in to avoid surprising network behavior) + announce: false + + # Discover peer gateways via mDNS browsing + # Default: false (opt-in to avoid surprising network behavior) + discover: false + + # mDNS service type for discovery/announcement + # Default: "_medkit._tcp.local" + mdns_service: "_medkit._tcp.local" + + # mDNS instance name - must be unique per gateway instance. + # Used for announcing and self-discovery filtering. + # Default: "" (uses container/host hostname via gethostname()) + # Set explicitly when running multiple gateways on the same host. + mdns_name: "" + + # Security: Forward client Authorization headers to peer gateways. + # CAUTION: When true, auth tokens are sent to ALL peers including + # mDNS-discovered ones. A malicious peer could harvest tokens. + # Only enable when all peers are trusted and use the same JWT config. + # Default: false (safe default - no token forwarding) + forward_auth: false + + # Security: Require TLS (https://) for all peer communication. + # When true, peers with http:// URLs are rejected with an error log. + # When false, http:// peers produce a warning about cleartext traffic. + # Default: false + require_tls: false + + # URL scheme for mDNS-discovered peer URLs. + # mDNS SRV records provide hostname and port but not the URL scheme. + # Set to "https" when all peers use TLS. + # Options: "http", "https" + # Default: "http" + peer_scheme: "http" + + # Maximum number of peers that can be added via mDNS discovery. + # Prevents unbounded growth of the peer list from rogue mDNS announcements. + # Static peers do not count against this limit. + # Valid range: 1-1000 + # Default: 50 + max_discovered_peers: 50 + + # Static peers (override or supplement mDNS discovery): + # Parallel arrays: peer_urls[i] pairs with peer_names[i] + # peer_urls: ["http://localhost:8081"] + # peer_names: ["subsystem_b"] diff --git a/src/ros2_medkit_gateway/design/aggregation.rst b/src/ros2_medkit_gateway/design/aggregation.rst new file mode 100644 index 000000000..3d112fb0b --- /dev/null +++ b/src/ros2_medkit_gateway/design/aggregation.rst @@ -0,0 +1,442 @@ +Multi-Instance Aggregation +========================== + +This document describes the design of multi-instance peer aggregation in +ros2_medkit_gateway. It covers the entity model changes (SOVD alignment), +entity merge logic, request routing, and mDNS-based auto-discovery. + +.. contents:: Table of Contents + :local: + :depth: 3 + +Overview +-------- + +A single ros2_medkit_gateway instance discovers and serves the ROS 2 entities on +its local machine. In production systems, robots often run multiple processes +across several hosts, containers, or network segments. Multi-instance aggregation +allows a **primary gateway** to transparently merge entities from one or more +**peer gateways** into a single unified API. Clients see one entity tree and do +not need to know which gateway owns which entity. + +.. plantuml:: + :caption: Multi-Instance Aggregation - High-Level Architecture + + @startuml aggregation_overview + + skinparam linetype ortho + skinparam classAttributeIconSize 0 + + class Client << external >> + + package "Primary Gateway" { + class AggregationManager { + + fetch_all_peer_entities() + + fan_out_get() + + forward_request() + + check_all_health() + } + class EntityMerger { + + merge_areas() + + merge_functions() + + merge_components() + + merge_apps() + + get_routing_table() + } + class MdnsDiscovery { + + start() + + stop() + } + class EntityCache + } + + package "Peer Gateway B" { + class "REST_API_B" as restB + } + + package "Peer Gateway C" { + class "REST_API_C" as restC + } + + Client --> AggregationManager : HTTP request + AggregationManager --> EntityMerger : merge remote entities + AggregationManager --> restB : PeerClient + AggregationManager --> restC : PeerClient + AggregationManager --> EntityCache : update with merged entities + MdnsDiscovery ..> AggregationManager : add_discovered_peer() + EntityMerger ..> EntityCache : merged entity set + + @enduml + +Entity Model (SOVD Alignment) +----------------------------- + +Prior to this feature, the gateway used only **Areas**, **Components**, and +**Apps** as entity types. The SOVD spec (ISO 17978) defines a richer hierarchy +where **Components** represent physical hardware (ECUs, hosts) and **Functions** +represent logical capabilities. This feature aligns the entity model: + +- **Area** - Physical or logical domain. Manifest-defined only (never + auto-generated from namespaces). +- **Component** - Physical host or ECU. In runtime-only mode, the gateway + creates a single Component from the local hostname using ``HostInfoProvider``. + All Apps discovered on that host are children of this Component. +- **App** - Individual ROS 2 node (unchanged). +- **Function** - Logical capability grouping. In runtime-only mode, each + ROS 2 namespace becomes a Function entity, grouping the Apps that share + that namespace. In manifest mode, Functions are explicitly declared. + +This mapping aligns with the SOVD view where a Component is "what hosts the +software" and a Function is "what the software does". + +.. plantuml:: + :caption: SOVD Entity Model Alignment + + @startuml entity_model + + skinparam linetype ortho + skinparam classAttributeIconSize 0 + + title SOVD Entity Hierarchy + + class Area { + id: string + namespace_path: string + description: string + } + + class Component { + id: string + name: string + description: string + area: string + source: string + } + + class App { + id: string + name: string + namespace_path: string + component: string + source: string + } + + class Function { + id: string + name: string + hosts: list + source: string + } + + Area "1" *--> "*" Component : contains + Component "1" *--> "*" App : hosts + Function "1" o--> "*" App : groups + + note right of Component + In runtime mode: single host + Component from HostInfoProvider + with hostname, OS, arch. + end note + + note right of Function + In runtime mode: each namespace + becomes a Function entity. + In manifest mode: explicitly declared. + end note + + @enduml + +HostInfoProvider +~~~~~~~~~~~~~~~~ + +``HostInfoProvider`` reads local system information (hostname, OS from +``/etc/os-release``, CPU architecture from ``uname``) and creates a single +``Component`` entity representing the physical host. The Component ID is the +sanitized hostname (lowercase, dots replaced with underscores, truncated to +256 characters). + +This replaces the old "synthetic component per namespace" behavior in +runtime-only discovery mode. The single host Component gives the entity tree a +physically meaningful root, and namespace-based grouping is handled by Function +entities instead. + +Resource Collections on Functions and Areas +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Functions and Areas support the same resource collections as Components and Apps: + +- **data** - Aggregated topic data from all hosted entities +- **operations** - Aggregated services and actions from hosted entities +- **configurations** - Aggregated parameters from hosted entities +- **faults** - Aggregated faults from hosted entities +- **logs** - Aggregated log entries from hosted entities + +Requests to ``/functions/{id}/data`` are fan-out queries that collect data from +all entities listed in the Function's ``hosts`` field. Similarly, Area resource +collection requests aggregate from all Components contained in that Area. + +Entity Merge Logic +------------------ + +When entities arrive from a peer gateway, the ``EntityMerger`` applies +type-specific merge rules: + +.. plantuml:: + :caption: Entity Merge Logic by Type + + @startuml entity_merge + + title Entity Merge Rules + + start + :Receive remote entity from peer; + if (Entity type?) then (Area, Function, or Component) + if (Same ID exists locally?) then (yes) + :Merge into existing entity\n(combine relations/tags); + else (no) + :Add as new entity; + :Add routing entry; + endif + else (App) + if (Same ID exists locally?) then (yes) + :Prefix remote ID with\npeername__ separator; + else (no) + :Keep original ID; + endif + :Add routing entry; + endif + :Tag with peer source metadata; + stop + + @enduml + +**Merge rules summary:** + +- **Areas**: Merge by ID. If both local and remote have the same Area ID (e.g., + ``root``), they are combined into one entity. Remote-only Areas are added with + ``source: "peer:"``. No routing table entry is created for merged Areas + because the local gateway owns the merged entity. + +- **Functions**: Merge by ID, combining the ``hosts`` lists from both sides. + If both gateways expose a ``navigation`` Function, the merged entity lists + hosts from both gateways. Same ownership semantics as Areas. + +- **Components**: Merge by ID, combining tags and metadata. Components represent + physical hosts or ECUs defined in manifests - the same Component ID across + peers refers to the same physical entity. Remote-only Components get a routing + table entry. + +- **Apps**: Prefix on collision. If a remote App has the same ID as a local one, + the remote entity's ID is prefixed with ``peername__`` (double underscore + separator). Apps represent individual ROS 2 nodes with unique behavior - two + Apps with the same ID from different peers are different entities. + +The ``EntityMerger::SEPARATOR`` constant (``__``) is used as the prefix +separator for Apps. The routing table maps ``entity_id -> peer_name`` for +remote-only entities and prefixed Apps that need request forwarding. + +Request Routing +--------------- + +When a request arrives for an entity, ``HandlerContext`` checks the routing +table. If the entity is local, processing continues normally. If the entity +maps to a peer, the request is forwarded transparently: + +.. plantuml:: + :caption: Request Routing - Local vs Remote + + @startuml request_routing + + actor Client + participant "Primary Gateway" as primary + participant HandlerContext as ctx + participant AggregationManager as agg + participant "Peer Gateway" as peer + + Client -> primary : GET /api/v1/components/robot_arm__lidar/data + primary -> ctx : validate_entity_for_route() + ctx -> ctx : Look up "robot_arm__lidar" in routing table + ctx --> primary : entity found, peer = "robot_arm" + + primary -> agg : forward_request("robot_arm", req, res) + agg -> peer : GET /api/v1/components/lidar/data + note right + Path rewritten: strips peer prefix + from entity ID before forwarding + end note + peer --> agg : 200 OK + JSON data + agg --> primary : Copy response to client + primary --> Client : 200 OK + JSON data + + == Local Entity == + + Client -> primary : GET /api/v1/apps/my_node/data + primary -> ctx : validate_entity_for_route() + ctx -> ctx : "my_node" not in routing table + ctx --> primary : entity found, local + + primary -> primary : Process locally via DataAccessManager + primary --> Client : 200 OK + JSON data + + @enduml + +**Entity collection endpoints** (``GET /api/v1/areas``, ``/components``, +``/apps``, ``/functions``) serve from the local entity cache, which is +populated during periodic cache refresh cycles that fetch entities from all +healthy peers. + +**Fault collection** (``GET /api/v1/faults``) uses real-time **fan-out** via +``fan_out_get()``: the primary gateway sends the same request to all healthy +peers, collects the responses, and merges the ``items`` arrays. If some peers +fail, the response body includes ``x-medkit.partial: true`` and +``x-medkit.failed_peers``. + +Peer Discovery +-------------- + +Peers can be configured statically in the YAML config or discovered +automatically via mDNS. + +Static Peers +~~~~~~~~~~~~ + +Configure peers directly in ``gateway_params.yaml`` using parallel arrays: + +.. code-block:: yaml + + aggregation: + enabled: true + peer_urls: ["http://192.168.1.10:8080", "http://192.168.1.11:8080"] + peer_names: ["arm_controller", "base_platform"] + +Static peers are always present in the peer list regardless of mDNS settings. + +mDNS Auto-Discovery +~~~~~~~~~~~~~~~~~~~~ + +``MdnsDiscovery`` uses multicast DNS (via the ``mjansson/mdns`` header-only C +library) to announce and discover gateway instances on the local network. + +- **Announce**: A background thread responds to mDNS queries for the configured + service type (default: ``_medkit._tcp.local``). Other gateways on the network + discover this instance automatically. + +- **Browse**: A background thread periodically sends mDNS queries and processes + responses. When a new peer is found, ``AggregationManager::add_discovered_peer()`` + is called. When a peer sends a goodbye, ``remove_discovered_peer()`` is called. + +mDNS discovery works alongside static peers. A gateway can have both static +and dynamically discovered peers. + +Health Monitoring +~~~~~~~~~~~~~~~~~ + +``AggregationManager`` calls ``check_all_health()`` during each entity cache +refresh cycle (controlled by ``refresh_interval_ms``, default: 10000 ms). Each +``PeerClient`` GETs ``/api/v1/health`` on its peer. If the health check fails, +the peer is marked unhealthy and excluded from fan-out queries and entity +fetching. + +When a peer recovers (health check succeeds again), it is automatically +re-included. + +Stream Proxy +~~~~~~~~~~~~ + +For streaming connections (e.g., SSE fault subscriptions), the ``StreamProxy`` +interface provides transport-agnostic event proxying. The ``SSEStreamProxy`` +implementation connects to a peer's SSE endpoint and relays events back to the +primary gateway's client. Each ``StreamEvent`` carries the ``peer_name`` so the +aggregator can attribute events to their source. + +Deployment Topologies +--------------------- + +Star Topology +~~~~~~~~~~~~~ + +One primary gateway aggregates from multiple leaf gateways. Best for robots +with a central controller and peripheral subsystems: + +.. code-block:: text + + Client + | + Primary (host-A) + / | \ + B C D (leaf gateways) + +Each leaf gateway discovers its own ROS 2 subsystem. The primary merges all +entities and serves a unified view. + +Chain Topology +~~~~~~~~~~~~~~ + +Gateways are chained: A aggregates from B, which aggregates from C. Each +level in the chain sees the merged view of everything downstream: + +.. code-block:: text + + Client -> A -> B -> C + +Gateway A sees entities from A + B + C. Gateway B sees entities from B + C. +Gateway C sees only its own entities. + +This is useful for layered systems where subsystems have their own aggregation +level (e.g., a fleet gateway that aggregates per-robot gateways, which in turn +aggregate per-subsystem gateways). + +Containers on Same Host +~~~~~~~~~~~~~~~~~~~~~~~ + +Multiple ROS 2 subsystems run in separate containers on the same host. Each +container runs its own gateway instance. A host-level gateway aggregates from +all containers via ``localhost`` or Docker network: + +.. code-block:: text + + Client + | + Host Gateway (port 8080) + / \ + Container A Container B + (port 8081) (port 8082) + +mDNS discovery handles container-to-container communication automatically +when containers share a network. Static peers work for bridge-networked +containers where mDNS does not cross network boundaries. + +Key Classes +----------- + +``PeerClient`` + HTTP client for communicating with a single peer gateway. Supports health + checking, entity fetching, transparent request forwarding (proxy), and + JSON-parsed responses for fan-out merging. Thread-safe via atomic health + flag and mutex-guarded lazy client creation. + +``EntityMerger`` + Stateless merge engine that combines local and remote entity sets using + type-specific rules (merge by ID for Area/Function/Component, prefix on + collision for App). Produces a routing table mapping remote entity IDs to + peer names. + +``AggregationManager`` + Central coordinator that manages the set of ``PeerClient`` instances, runs + health checks, maintains the routing table, and provides fan-out and + forwarding APIs. Thread-safe via ``shared_mutex``. + +``MdnsDiscovery`` + Background service for announcing and browsing mDNS services. Runs + announce and browse threads. Invokes callbacks when peers are found or + removed. + +``StreamProxy`` / ``SSEStreamProxy`` + Transport-agnostic interface for proxying streaming connections to peers. + ``SSEStreamProxy`` implements SSE-based event relaying with a background + reader thread. + +``HostInfoProvider`` + Reads local host system info (hostname, OS, architecture) and produces a + single ``Component`` entity representing the physical host. Used in + runtime-only discovery to replace synthetic per-namespace Components. diff --git a/src/ros2_medkit_gateway/design/index.rst b/src/ros2_medkit_gateway/design/index.rst index dd04637e8..4bf1eafc6 100644 --- a/src/ros2_medkit_gateway/design/index.rst +++ b/src/ros2_medkit_gateway/design/index.rst @@ -58,8 +58,8 @@ The following diagram shows the relationships between the main components of the } class RuntimeDiscoveryStrategy { - + discover_synthetic_components(): vector - + discover_topic_components(): vector + + discover_apps(): vector + + discover_functions(): vector - config_: RuntimeConfig } @@ -392,8 +392,8 @@ Main Components - **RuntimeDiscoveryStrategy** - Heuristic discovery via ROS 2 graph introspection - Maps nodes to Apps with ``source: "heuristic"`` - - Creates synthetic Components grouped by namespace - - Handles topic-only namespaces (Isaac Sim, bridges) via TopicOnlyPolicy + - Creates Functions from namespace grouping + - Never creates Areas or Components (those come from manifest/HostInfoProvider) - **ManifestDiscoveryStrategy** - Static discovery from YAML manifest - Provides stable, semantic entity IDs - Supports offline detection of failed components @@ -546,3 +546,11 @@ It consists of five main components: - Reference-counted: multiple triggers on the same topic share one subscription - Publishes data changes to ``ResourceChangeNotifier`` for condition evaluation - Automatically unsubscribes when the last trigger for a topic is removed + +Additional Design Documents +---------------------------- + +.. toctree:: + :maxdepth: 1 + + aggregation diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/aggregation_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/aggregation_manager.hpp new file mode 100644 index 000000000..0a3b6ca8b --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/aggregation_manager.hpp @@ -0,0 +1,251 @@ +// 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 +#include + +#include "ros2_medkit_gateway/aggregation/peer_client.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Configuration for peer aggregation + */ +struct AggregationConfig { + bool enabled{false}; + int timeout_ms{2000}; + bool announce{false}; + bool discover{false}; + std::string mdns_service{"_medkit._tcp.local"}; + + /// Forward the client's Authorization header to peer gateways. + /// Default: false (safe default - prevents token leakage to untrusted peers). + bool forward_auth{false}; + + /// Require TLS (https://) for all peer URLs. + /// When true, peers with http:// URLs are rejected (logged as ERROR and skipped). + /// When false, http:// peers produce a WARN at startup about cleartext communication. + bool require_tls{false}; + + /// URL scheme for mDNS-discovered peer URLs ("http" or "https"). + /// Default: "http". Set to "https" when peers use TLS. + std::string peer_scheme{"http"}; + + /// Maximum number of peers that can be added via mDNS discovery. + /// Prevents unbounded growth of the peer list from rogue mDNS announcements. + /// Static peers do not count against this limit. + /// Default: 50. + size_t max_discovered_peers{50}; + + struct PeerConfig { + std::string url; + std::string name; + }; + std::vector peers; +}; + +/** + * @brief Coordinator for peer aggregation + * + * Manages PeerClients, health monitoring, routing table, entity merging, + * and fan-out logic. Thread-safe: shared_mutex protects peers_ and + * routing_table_. Readers use shared lock, writers use exclusive lock. + */ +class AggregationManager { + public: + /** + * @brief Result of fetching and merging entities from all healthy peers + * + * Contains merged entity vectors and a routing table mapping remote entity + * IDs to the peer name that owns them. + */ + struct MergedPeerResult { + std::vector areas; + std::vector components; + std::vector apps; + std::vector functions; + std::unordered_map routing_table; + }; + + /** + * @brief Result of a fan-out GET across all healthy peers + */ + struct FanOutResult { + nlohmann::json merged_items; ///< Merged "items" array from all peers + bool is_partial{false}; ///< True if some peers failed + std::vector failed_peers; ///< Names of peers that failed + }; + + /** + * @brief Construct an AggregationManager from config + * + * Creates a PeerClient for each statically configured peer. Validates + * TLS requirements and logs warnings for cleartext peer URLs. + * + * @param config Aggregation configuration + * @param logger Optional logger for TLS warnings (pass nullptr to suppress) + */ + explicit AggregationManager(const AggregationConfig & config, rclcpp::Logger * logger = nullptr); + + /// Get the number of known peers (static + discovered) + size_t peer_count() const; + + /** + * @brief Add a dynamically discovered peer + * + * Thread-safe. If a peer with the given name already exists, this is a no-op. + * + * @param url Base URL of the peer + * @param name Human-readable peer name + */ + void add_discovered_peer(const std::string & url, const std::string & name); + + /** + * @brief Remove a dynamically discovered peer by name + * + * Thread-safe. If the peer is not found, this is a no-op. + * + * @param name Peer name to remove + */ + void remove_discovered_peer(const std::string & name); + + /** + * @brief Check health of all peers + * + * Calls check_health() on each PeerClient. + */ + void check_all_health(); + + /** + * @brief Get count of currently healthy peers + * @return Number of peers that report healthy status + */ + size_t healthy_peer_count() const; + + /** + * @brief Fetch entities from all healthy peers and merge them + * @return Merged PeerEntities from all reachable peers + */ + PeerEntities fetch_all_peer_entities(); + + /** + * @brief Fetch entities from all healthy peers, merge with local entities, and build routing table + * + * Snapshots peer shared_ptrs under lock, releases before network I/O. The + * shared_ptr copies keep PeerClients alive even if remove_discovered_peer() + * runs concurrently. Uses EntityMerger per-peer so that collision-prefixed + * IDs are correctly tracked in the routing table. + * + * @param local_areas Local areas to merge with + * @param local_components Local components to merge with + * @param local_apps Local apps to merge with + * @param local_functions Local functions to merge with + * @param max_entities_per_peer Maximum total entities accepted from a single peer + * @param logger Optional logger for warnings (pass nullptr to suppress) + * @return MergedPeerResult with merged entity vectors and routing table + */ + MergedPeerResult + fetch_and_merge_peer_entities(const std::vector & local_areas, const std::vector & local_components, + const std::vector & local_apps, const std::vector & local_functions, + size_t max_entities_per_peer = 10000, rclcpp::Logger * logger = nullptr); + + /** + * @brief Update the routing table (entity_id -> peer_name) + * @param table New routing table to replace the current one + */ + void update_routing_table(const std::unordered_map & table); + + /** + * @brief Look up which peer owns a given entity + * @param entity_id Entity ID to look up + * @return Peer name if entity is remote, std::nullopt if local or unknown + */ + std::optional find_peer_for_entity(const std::string & entity_id) const; + + /** + * @brief Get the URL for a known peer by name + * @param peer_name Name of the peer + * @return URL if found, empty string otherwise + */ + std::string get_peer_url(const std::string & peer_name) const; + + /** + * @brief Forward an HTTP request to a specific peer + * + * Finds the PeerClient by name and calls forward_request(). + * If the peer is not found, sends a 502 error response. + * + * @param peer_name Name of the target peer + * @param req Incoming HTTP request to forward + * @param res Outgoing HTTP response to populate + */ + void forward_request(const std::string & peer_name, const httplib::Request & req, httplib::Response & res); + + /** + * @brief Fan-out a GET request to all healthy peers in parallel + * + * Sends GET requests to all healthy peers concurrently via std::async, + * merges the "items" arrays from their responses. Returns partial results + * if some peers fail. + * + * @param path Request path (e.g., "/api/v1/components") + * @param auth_header Authorization header value (empty to omit) + * @return FanOutResult with merged items and failure info + */ + FanOutResult fan_out_get(const std::string & path, const std::string & auth_header); + + /** + * @brief Get peer status for /health endpoint + * @return JSON array of peer objects with name, url, status + */ + nlohmann::json get_peer_status() const; + + private: + AggregationConfig config_; + rclcpp::Logger logger_; + size_t static_peer_count_{0}; ///< Number of statically configured peers (not subject to max_discovered_peers) + mutable std::shared_mutex mutex_; // Declared before data it protects (destruction order) + std::vector> peers_; + std::unordered_map routing_table_; + + /** + * @brief Find a peer by name (caller must hold lock) + * @param name Peer name to search for + * @return Raw pointer to PeerClient, or nullptr if not found + */ + PeerClient * find_peer(const std::string & name) const; + + /** + * @brief Find a peer by name and return a shared_ptr (caller must hold lock) + * + * Returns a shared_ptr copy that keeps the PeerClient alive after the caller + * releases the lock, enabling lock-free network I/O. + * + * @param name Peer name to search for + * @return shared_ptr to PeerClient, or nullptr if not found + */ + std::shared_ptr find_peer_shared(const std::string & name) const; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/entity_merger.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/entity_merger.hpp new file mode 100644 index 000000000..a7cf96d72 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/entity_merger.hpp @@ -0,0 +1,101 @@ +// 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 "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Merges entities from a remote peer gateway with local entities. + * + * Type-aware merge logic: + * - Area: merge by ID (combine tags/description, no duplication) + * - Function: merge by ID (combine hosts lists) + * - Component: merge by ID (combine tags/description, no duplication) + * - App: prefix remote ID with "peername__" on collision + * + * After merging, a routing table maps remote entity IDs (possibly prefixed) + * to the peer name, enabling request forwarding. Merged entities (Area, + * Function, Component with same ID) do NOT appear in the routing table. + */ +class EntityMerger { + public: + explicit EntityMerger(const std::string & peer_name); + + /** + * @brief Merge local and remote Areas by ID. + * + * Same area ID from both local and remote yields one Area entity. + * Remote-only areas are added with source tagged as "peer:". + */ + std::vector merge_areas(const std::vector & local, const std::vector & remote); + + /** + * @brief Merge local and remote Functions by ID, combining hosts lists. + * + * Same function ID from both local and remote yields one Function with + * the union of hosts from both sides. + */ + std::vector merge_functions(const std::vector & local, const std::vector & remote); + + /** + * @brief Merge local and remote Components by ID. + * + * Same component ID from both local and remote yields one Component entity + * with merged tags and description. Remote-only components are added with + * source tagged as "peer:". + */ + std::vector merge_components(const std::vector & local, const std::vector & remote); + + /** + * @brief Merge local and remote Apps with prefix on collision. + * + * If a remote App has the same ID as a local one, the remote entity + * gets its ID prefixed with "peername__". + */ + std::vector merge_apps(const std::vector & local, const std::vector & remote); + + /** + * @brief Get the routing table: entity_id -> peer_name for remote entities. + * + * Prefixed entities map their prefixed ID. Merged entities (Area/Function) + * do not appear in the routing table. Non-colliding remote Components/Apps + * still appear because they need request forwarding. + */ + const std::unordered_map & get_routing_table() const; + + /// Separator used for prefixing remote entity IDs on collision. + static constexpr const char * SEPARATOR = "__"; + + private: + std::string peer_name_; + std::unordered_map routing_table_; + + /// Create a prefixed ID: "peername__original_id" + std::string prefix_id(const std::string & id) const; + + /// Create the source tag for remote entities: "peer:" + std::string peer_source() const; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/mdns_discovery.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/mdns_discovery.hpp new file mode 100644 index 000000000..898fbbb71 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/mdns_discovery.hpp @@ -0,0 +1,126 @@ +// 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 + +namespace ros2_medkit_gateway { + +/** + * @brief mDNS-based discovery for auto-discovering peer gateways + * + * Uses the mjansson/mdns header-only C library to announce this gateway's + * presence on the local network and browse for other gateways. Peers are + * discovered via the configured service type (default: _medkit._tcp.local). + * + * Thread safety: start()/stop() are not thread-safe with respect to each other. + * The running_ flag is atomic and used for clean shutdown of background threads. + */ +class MdnsDiscovery { + public: + /** + * @brief Configuration for mDNS discovery + */ + /// Callback invoked when an mDNS operation encounters an error (e.g., socket failure) + using ErrorCallback = std::function; + + /// Callback for diagnostic logging (wired to RCLCPP_DEBUG in gateway) + using LogCallback = std::function; + + struct Config { + bool announce{false}; ///< Broadcast presence via mDNS + bool discover{false}; ///< Browse for peers via mDNS + std::string service{"_medkit._tcp.local"}; ///< Service type to announce/browse + int port{8080}; ///< Port this gateway listens on + std::string name; ///< Instance name for announcement + std::string peer_scheme{"http"}; ///< URL scheme for discovered peers ("http" or "https") + ErrorCallback on_error; ///< Optional error reporting callback + LogCallback on_log; ///< Optional diagnostic log callback + }; + + /// Callback invoked when a peer gateway is discovered + using PeerFoundCallback = std::function; + + /// Callback invoked when a peer gateway is removed (goodbye received) + using PeerRemovedCallback = std::function; + + /** + * @brief Construct an MdnsDiscovery instance + * @param config Configuration controlling announce/discover behavior + */ + explicit MdnsDiscovery(const Config & config); + + /// Destructor calls stop() to ensure clean thread shutdown + ~MdnsDiscovery(); + + // Non-copyable, non-movable (owns threads) + MdnsDiscovery(const MdnsDiscovery &) = delete; + MdnsDiscovery & operator=(const MdnsDiscovery &) = delete; + MdnsDiscovery(MdnsDiscovery &&) = delete; + MdnsDiscovery & operator=(MdnsDiscovery &&) = delete; + + /** + * @brief Start mDNS announce and/or browse threads + * + * Starts background threads based on config flags: + * - If announce is true, starts a thread that responds to mDNS queries + * - If discover is true, starts a thread that sends mDNS queries and + * invokes callbacks when peers are found or removed + * + * @param on_found Callback for when a peer is discovered + * @param on_removed Callback for when a peer sends a goodbye + */ + void start(PeerFoundCallback on_found, PeerRemovedCallback on_removed); + + /** + * @brief Stop all mDNS threads + * + * Sets running_ to false and joins all background threads. Safe to call + * multiple times (idempotent). + */ + void stop(); + + /// Check if the announce thread is running + bool is_announcing() const; + + /// Check if the discover/browse thread is running + bool is_discovering() const; + + /// Get the resolved mDNS instance name (hostname if not explicitly set) + const std::string & instance_name() const { + return config_.name; + } + + private: + /// Main loop for the announce thread (listens for queries and responds) + void announce_loop(); + + /// Main loop for the browse thread (sends queries and processes responses) + void browse_loop(); + + Config config_; + std::atomic running_{false}; + std::atomic announcing_{false}; + std::atomic discovering_{false}; + PeerFoundCallback on_found_; + PeerRemovedCallback on_removed_; + std::thread announce_thread_; + std::thread browse_thread_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/network_utils.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/network_utils.hpp new file mode 100644 index 000000000..04de0b0ba --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/network_utils.hpp @@ -0,0 +1,124 @@ +// 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 + +namespace ros2_medkit_gateway { + +/** + * @brief Collect all IPv4 and IPv6 addresses assigned to local network interfaces. + * + * Always includes 127.0.0.1 and ::1 (loopback). Used for self-discovery filtering + * so the gateway never forwards to itself even if an mDNS response spoofs the name. + * + * @return Set of IP address strings (e.g. "192.168.1.5", "::1") + */ +inline std::unordered_set collect_local_addresses() { + std::unordered_set addrs; + addrs.insert("127.0.0.1"); + addrs.insert("::1"); + + struct ifaddrs * ifaddr = nullptr; + if (getifaddrs(&ifaddr) == -1) { + return addrs; + } + + for (struct ifaddrs * ifa = ifaddr; ifa != nullptr; ifa = ifa->ifa_next) { + if (ifa->ifa_addr == nullptr) { + continue; + } + std::array buf{}; + if (ifa->ifa_addr->sa_family == AF_INET) { + auto * addr4 = reinterpret_cast(ifa->ifa_addr); + inet_ntop(AF_INET, &addr4->sin_addr, buf.data(), buf.size()); + addrs.insert(buf.data()); + } else if (ifa->ifa_addr->sa_family == AF_INET6) { + auto * addr6 = reinterpret_cast(ifa->ifa_addr); + inet_ntop(AF_INET6, &addr6->sin6_addr, buf.data(), buf.size()); + addrs.insert(buf.data()); + } + } + + freeifaddrs(ifaddr); + return addrs; +} + +/** + * @brief Parse host and port from a URL of the form "scheme://host:port[/path]". + * + * Handles IPv6 bracket notation (e.g. "http://[::1]:8080/api"). + * + * @param url Full URL string + * @return Pair of {host, port}. Returns {"", -1} if parsing fails entirely. + * Returns {host, -1} if port is missing. + */ +inline std::pair parse_url_host_port(const std::string & url) { + // Find "://" + auto scheme_end = url.find("://"); + if (scheme_end == std::string::npos) { + return {"", -1}; + } + std::string rest = url.substr(scheme_end + 3); + + // Strip any path + auto path_pos = rest.find('/'); + if (path_pos != std::string::npos) { + rest = rest.substr(0, path_pos); + } + + std::string host; + std::string port_str; + + if (!rest.empty() && rest[0] == '[') { + // IPv6 bracket notation: [addr]:port + auto bracket_end = rest.find(']'); + if (bracket_end == std::string::npos) { + return {"", -1}; + } + host = rest.substr(1, bracket_end - 1); + if (bracket_end + 1 < rest.size() && rest[bracket_end + 1] == ':') { + port_str = rest.substr(bracket_end + 2); + } + } else { + // IPv4 or hostname: host:port + auto colon_pos = rest.rfind(':'); + if (colon_pos == std::string::npos) { + return {rest, -1}; + } + host = rest.substr(0, colon_pos); + port_str = rest.substr(colon_pos + 1); + } + + int port = -1; + if (!port_str.empty()) { + try { + port = std::stoi(port_str); + } catch (...) { + return {host, -1}; + } + } + + return {host, port}; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/peer_client.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/peer_client.hpp new file mode 100644 index 000000000..816027c47 --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/peer_client.hpp @@ -0,0 +1,139 @@ +// 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 + +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" + +namespace ros2_medkit_gateway { + +/** + * @brief Collection of entities fetched from a peer gateway + */ +struct PeerEntities { + std::vector areas; + std::vector components; + std::vector apps; + std::vector functions; +}; + +/** + * @brief HTTP client for communicating with a peer gateway instance + * + * PeerClient wraps cpp-httplib to provide typed access to a peer gateway's + * REST API. It supports health checking, entity fetching, transparent request + * forwarding (proxy), and JSON fan-out queries. + * + * Thread safety: The healthy_ flag is atomic. Client creation is lazy and + * guarded by a mutex. All public methods are safe to call from any thread. + */ +class PeerClient { + public: + /** + * @brief Construct a PeerClient for a peer gateway + * @param url Base URL of the peer (e.g., "http://localhost:8081") + * @param name Human-readable peer name (e.g., "subsystem_b") + * @param timeout_ms Connection and read timeout in milliseconds + * @param forward_auth Whether to forward Authorization headers to this peer + */ + PeerClient(const std::string & url, const std::string & name, int timeout_ms, bool forward_auth = false); + + /// Get the peer base URL + const std::string & url() const; + + /// Get the peer name + const std::string & name() const; + + /// Check if the peer was healthy at last health check + bool is_healthy() const; + + /** + * @brief Perform a health check against the peer + * + * GETs /api/v1/health on the peer. Sets the internal healthy_ flag + * based on whether a 200 response was received. + */ + void check_health(); + + /** + * @brief Fetch all entity collections from the peer + * + * GETs /api/v1/areas, /api/v1/components, /api/v1/apps, /api/v1/functions + * and parses the items[] arrays. Each entity's source is set to "peer:". + * + * @return PeerEntities on success, error message on failure + */ + tl::expected fetch_entities(); + + /** + * @brief Forward an HTTP request transparently to the peer (proxy) + * + * Copies method, path, body, and Content-Type from the incoming request. + * Forwards the Authorization header only if forward_auth is enabled. + * Copies the peer's response status, headers, and body back to the + * outgoing response. + * + * On connection failure, returns 502 with x-medkit-peer-unavailable error. + * + * @param req Incoming HTTP request to forward + * @param res Outgoing HTTP response to populate + */ + void forward_request(const httplib::Request & req, httplib::Response & res); + + /** + * @brief Forward a request and parse the JSON response + * + * Used for fan-out merge scenarios where the aggregator needs to + * combine JSON results from multiple peers. + * + * @param method HTTP method (e.g., "GET") + * @param path Request path (e.g., "/api/v1/components/abc/data") + * @param auth_header Authorization header value (empty to omit) + * @return Parsed JSON on success, error message on failure + */ + tl::expected forward_and_get_json(const std::string & method, const std::string & path, + const std::string & auth_header = ""); + + private: + /** + * @brief Ensure the underlying HTTP client exists (lazy initialization) + * + * Must be called under client_mutex_. Creates the client if it doesn't exist. + */ + void ensure_client(); + + std::string url_; + std::string name_; + int timeout_ms_; + bool forward_auth_; + std::atomic healthy_{false}; + + std::mutex client_mutex_; + std::unique_ptr client_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/stream_proxy.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/stream_proxy.hpp new file mode 100644 index 000000000..8edd248dc --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/aggregation/stream_proxy.hpp @@ -0,0 +1,126 @@ +// 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 + +namespace ros2_medkit_gateway { + +/** + * @brief A single event received from a streaming connection + * + * Represents an SSE event with type, data payload, optional ID, and + * the name of the peer gateway that produced it. + */ +struct StreamEvent { + std::string event_type; ///< SSE event name (e.g., "data", "fault") + std::string data; ///< Event data (JSON string) + std::string id; ///< Event ID (optional) + std::string peer_name; ///< Which peer this event came from +}; + +/** + * @brief Transport-agnostic interface for proxying streaming connections + * + * StreamProxy abstracts the transport layer for streaming event connections + * to peer gateways. Currently implemented with SSE, but the interface allows + * future implementations using WebSocket or gRPC streams. + * + * Usage: + * proxy->on_event([](const StreamEvent& e) { handle(e); }); + * proxy->open(); + * // ... events flow via callback ... + * proxy->close(); + */ +class StreamProxy { + public: + virtual ~StreamProxy() = default; + + /// Start the streaming connection (non-blocking, spawns reader thread) + virtual void open() = 0; + + /// Stop the streaming connection and join the reader thread + virtual void close() = 0; + + /// Check if the streaming connection is currently active + virtual bool is_connected() const = 0; + + /// Register a callback for incoming stream events + virtual void on_event(std::function cb) = 0; +}; + +/** + * @brief SSE (Server-Sent Events) implementation of StreamProxy + * + * Connects to a peer gateway's SSE endpoint using cpp-httplib and parses + * the SSE text/event-stream format into StreamEvent objects. Runs a + * background reader thread that invokes the registered callback for each + * parsed event. + * + * Thread safety: connected_ and should_stop_ are atomic. The callback + * is set before open() and not modified afterwards. + */ +class SSEStreamProxy : public StreamProxy { + public: + /** + * @brief Construct an SSEStreamProxy + * @param peer_url Base URL of the peer gateway (e.g., "http://localhost:8081") + * @param path SSE endpoint path (e.g., "/api/v1/components/abc/faults/sse") + * @param peer_name Human-readable name for the peer (used in StreamEvent::peer_name) + */ + SSEStreamProxy(const std::string & peer_url, const std::string & path, const std::string & peer_name = ""); + + ~SSEStreamProxy() override; + + void open() override; + void close() override; + bool is_connected() const override; + void on_event(std::function cb) override; + + /** + * @brief Parse raw SSE text/event-stream data into StreamEvent objects + * + * SSE format: events are separated by blank lines. Each event consists + * of field lines: "event:", "data:", "id:". Multiple "data:" lines are + * joined with newlines. + * + * This is a pure function suitable for unit testing without networking. + * + * @param raw Raw SSE text data + * @param peer Peer name to set on each parsed event + * @return Vector of parsed StreamEvent objects + */ + static std::vector parse_sse_data(const std::string & raw, const std::string & peer); + + private: + /// Background thread loop that connects and reads SSE events + void reader_loop(); + + std::string peer_url_; + std::string path_; + std::string peer_name_; + std::atomic connected_{false}; + std::atomic should_stop_{false}; + std::function callback_; + std::thread reader_thread_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp index 11cb4a707..f4d02fe36 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_enums.hpp @@ -41,53 +41,4 @@ DiscoveryMode parse_discovery_mode(const std::string & str); */ std::string discovery_mode_to_string(DiscoveryMode mode); -/** - * @brief Strategy for grouping nodes into synthetic components - */ -enum class ComponentGroupingStrategy { - NONE, ///< Each node = 1 component (current behavior) - NAMESPACE, ///< Group by first namespace segment (area) - // PROCESS // Group by OS process (future - requires R&D) -}; - -/** - * @brief Parse ComponentGroupingStrategy from string - * @param str Strategy string: "none" or "namespace" - * @return Parsed strategy (defaults to NONE) - */ -ComponentGroupingStrategy parse_grouping_strategy(const std::string & str); - -/** - * @brief Convert ComponentGroupingStrategy to string - * @param strategy Grouping strategy - * @return String representation - */ -std::string grouping_strategy_to_string(ComponentGroupingStrategy strategy); - -/** - * @brief Policy for handling namespaces with topics but no nodes - * - * When topics exist in a namespace without any ROS 2 nodes (common with - * Isaac Sim, external bridges), this policy determines what entities to create. - */ -enum class TopicOnlyPolicy { - IGNORE, ///< Don't create any entity for topic-only namespaces - CREATE_COMPONENT, ///< Create component with source="topic" (default) - CREATE_AREA_ONLY ///< Only create the area, no component -}; - -/** - * @brief Parse TopicOnlyPolicy from string - * @param str Policy string: "ignore", "create_component", or "create_area_only" - * @return Parsed policy (defaults to CREATE_COMPONENT) - */ -TopicOnlyPolicy parse_topic_only_policy(const std::string & str); - -/** - * @brief Convert TopicOnlyPolicy to string - * @param policy Topic-only policy - * @return String representation - */ -std::string topic_only_policy_to_string(TopicOnlyPolicy policy); - } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp index fde0f6913..a5f421199 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/discovery_manager.hpp @@ -16,6 +16,7 @@ #include "ros2_medkit_gateway/discovery/discovery_enums.hpp" #include "ros2_medkit_gateway/discovery/discovery_strategy.hpp" +#include "ros2_medkit_gateway/discovery/host_info_provider.hpp" #include "ros2_medkit_gateway/discovery/hybrid_discovery.hpp" #include "ros2_medkit_gateway/discovery/manifest/manifest_manager.hpp" #include "ros2_medkit_gateway/discovery/merge_types.hpp" @@ -55,61 +56,42 @@ struct DiscoveryConfig { * * These options control how the heuristic discovery strategy * maps ROS 2 graph entities to SOVD entities. + * + * Note: Synthetic/heuristic Area and Component creation has been removed. + * Areas come from manifest only. Components come from HostInfoProvider + * or manifest. Namespaces create Function entities. */ struct RuntimeOptions { /** - * @brief Create synthetic Area entities from ROS 2 namespaces - * - * When true (default), namespaces become Areas. - * When false, no Areas are created - flat component tree. - * Useful for simple robots without area hierarchy. - */ - bool create_synthetic_areas{true}; - - /** - * @brief Create synthetic Component entities that group Apps - * - * When true, Components are synthetic groupings (by namespace). - * When false, each node is a Component (legacy behavior). - * Default: true (new behavior for initial release) - */ - bool create_synthetic_components{true}; - - /** - * @brief How to group nodes into synthetic components + * @brief Create Function entities from ROS 2 namespace grouping * - * Only used when create_synthetic_components is true. - * - NONE: Each node = 1 component - * - NAMESPACE: Group by first namespace segment (area) + * When true (default), namespaces are mapped to Function entities. + * Each Function hosts the Apps in that namespace. + * This is the SOVD-correct mapping: namespaces represent + * functional grouping, not deployment topology. */ - ComponentGroupingStrategy grouping{ComponentGroupingStrategy::NAMESPACE}; + bool create_functions_from_namespaces{true}; /** - * @brief Naming pattern for synthetic components + * @brief Create a default Component from HostInfoProvider * - * Placeholders: {area} - * Default: "{area}" - uses area name as component ID + * When true (default), a single host-level Component is created + * from system info (hostname, OS, arch) instead of synthetic + * per-namespace Components. All discovered Apps are linked to + * this Component via the is-located-on relationship. + * Only used in runtime_only mode. */ - std::string synthetic_component_name_pattern{"{area}"}; + bool default_component_enabled{true}; /** - * @brief Policy for handling topic-only namespaces + * @brief Filter ROS 2 internal nodes from entity discovery * - * When topics exist in a namespace without ROS 2 nodes: - * - IGNORE: Don't create any entity - * - CREATE_COMPONENT: Create component with source="topic" (default) - * - CREATE_AREA_ONLY: Only create the area, no component + * When true (default), apps whose ID starts with underscore (_) are + * filtered out. These are ROS 2 infrastructure nodes like + * _param_client_node that should not appear as SOVD entities. + * Applies to both local and peer-discovered apps. */ - TopicOnlyPolicy topic_only_policy{TopicOnlyPolicy::CREATE_COMPONENT}; - - /** - * @brief Minimum number of topics to create a component - * - * Only applies when topic_only_policy is CREATE_COMPONENT. - * Namespaces with fewer topics than this threshold are skipped. - * Default: 1 (create component for any namespace with topics) - */ - int min_topics_for_component{1}; + bool filter_internal_nodes{true}; } runtime; /** @@ -189,6 +171,18 @@ class DiscoveryManager { */ std::vector discover_functions(); + /** + * @brief Discover functions using pre-discovered apps + * + * Avoids redundant ROS 2 graph introspection when apps have already been + * discovered in the same refresh cycle. Falls back to the no-arg overload + * for modes that don't support this optimization (manifest-only, hybrid). + * + * @param apps Pre-discovered apps (used only in RUNTIME_ONLY mode) + * @return Vector of discovered Function entities + */ + std::vector discover_functions(const std::vector & apps); + // ========================================================================= // Entity lookup by ID // ========================================================================= @@ -264,13 +258,6 @@ class DiscoveryManager { // Runtime-specific methods (delegate to runtime strategy) // ========================================================================= - /** - * @brief Discover components from topic namespaces - * @return Vector of topic-based components - * @see discovery::RuntimeDiscoveryStrategy::discover_topic_components - */ - std::vector discover_topic_components(); - /** * @brief Discover all services in the system * @return Vector of ServiceInfo with schema information @@ -354,6 +341,18 @@ class DiscoveryManager { // Status // ========================================================================= + /** + * @brief Check if a host info provider is active + * @return true if default component is enabled and provider exists + */ + bool has_host_info_provider() const; + + /** + * @brief Get the default Component from HostInfoProvider + * @return Component entity representing the local host, or nullopt if not enabled + */ + std::optional get_default_component() const; + /** * @brief Get current discovery mode * @return Active discovery mode @@ -395,6 +394,9 @@ class DiscoveryManager { rclcpp::Node * node_; DiscoveryConfig config_; + // Host info provider (created when default_component_enabled is true) + std::unique_ptr host_info_provider_; + // Strategies std::unique_ptr runtime_strategy_; std::unique_ptr manifest_manager_; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/host_info_provider.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/host_info_provider.hpp new file mode 100644 index 000000000..0ad691aea --- /dev/null +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/host_info_provider.hpp @@ -0,0 +1,85 @@ +// 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 "ros2_medkit_gateway/discovery/models/component.hpp" + +#include + +namespace ros2_medkit_gateway { + +/** + * @brief Reads local host system info and produces a default Component entity. + * + * Used in runtime_only discovery mode to create a single host-level Component + * instead of synthetic per-namespace Components. + */ +class HostInfoProvider { + public: + HostInfoProvider(); + + /** + * @brief Get the default Component representing the local host system. + * + * The Component has: + * - id = sanitized hostname (alphanumeric + underscore + hyphen, lowercase) + * - name = raw hostname + * - source = "runtime" + * - description = "OS on arch" + * - x-medkit.host metadata with hostname, os, arch + * + * @return Component entity for the local host + */ + const Component & get_default_component() const { + return component_; + } + + /** + * @brief Sanitize a string to a valid SOVD entity ID. + * + * Converts dots and spaces to underscores, strips other invalid characters, + * converts to lowercase, and truncates to 256 characters. + * + * @param input Raw string (e.g., hostname) + * @return Sanitized entity ID (alphanumeric + underscore + hyphen only) + */ + static std::string sanitize_entity_id(const std::string & input); + + /// @brief Get raw hostname + const std::string & hostname() const { + return hostname_; + } + + /// @brief Get OS description (from /etc/os-release PRETTY_NAME) + const std::string & os() const { + return os_; + } + + /// @brief Get CPU architecture (from uname) + const std::string & arch() const { + return arch_; + } + + private: + void read_host_info(); + void build_component(); + + std::string hostname_; + std::string os_; + std::string arch_; + Component component_; +}; + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/merge_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/merge_types.hpp index 2d6298ead..900604d68 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/merge_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/merge_types.hpp @@ -141,10 +141,11 @@ struct MergeReport { * * When manifest is present, runtime entities fill gaps. This struct * controls which entity types and namespaces are eligible for gap-fill. + * + * Note: allow_heuristic_areas and allow_heuristic_components have been + * removed because runtime discovery no longer creates Areas or Components. */ struct GapFillConfig { - bool allow_heuristic_areas{true}; - bool allow_heuristic_components{true}; bool allow_heuristic_apps{true}; bool allow_heuristic_functions{false}; std::vector namespace_whitelist; @@ -162,5 +163,16 @@ inline bool is_runtime_source(const std::string & source) { return source == "heuristic" || source == "topic" || source == "synthetic" || source == "node"; } +/** + * @brief Check if an entity source is protected from orphan suppression + * + * Whitelist approach: only manifest and plugin sources are preserved during + * orphan filtering. Everything else (heuristic, topic, synthetic, node, runtime, + * peer:xxx) is eligible for suppression. + */ +inline bool is_protected_source(const std::string & source) { + return source == "manifest" || source.rfind("plugin", 0) == 0; +} + } // namespace discovery } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp index 6dda91c20..fb4585ffe 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/component.hpp @@ -17,6 +17,7 @@ #include "ros2_medkit_gateway/discovery/models/common.hpp" #include +#include #include #include @@ -47,6 +48,7 @@ struct Component { std::vector services; ///< Services exposed by this component std::vector actions; ///< Actions exposed by this component ComponentTopics topics; ///< Topics this component publishes/subscribes + std::optional host_metadata; ///< Host system metadata (for runtime default component) /** * @brief Convert to JSON representation @@ -85,6 +87,9 @@ struct Component { x_medkit["dependsOn"] = depends_on; } x_medkit["topics"] = topics.to_json(); + if (host_metadata.has_value()) { + x_medkit["host"] = host_metadata.value(); + } j["x-medkit"] = x_medkit; // Add operations array combining services and actions diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp index 0b2a10b1e..01c5a7e88 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/models/function.hpp @@ -28,7 +28,7 @@ using json = nlohmann::json; * Functions are higher-level abstractions that group Apps and/or Components * representing a complete capability (e.g., "Autonomous Navigation"). * - * Functions are always manifest-defined and don't exist at runtime by themselves. + * Functions can be manifest-defined or created at runtime from namespace grouping. * They aggregate data, operations, and faults from their hosted entities. */ struct Function { @@ -46,7 +46,7 @@ struct Function { std::vector depends_on; ///< depends-on relationship (Function IDs) // === Discovery metadata === - std::string source = "manifest"; ///< Always "manifest" (functions don't exist at runtime) + std::string source = "manifest"; ///< Discovery source: manifest or runtime // === Serialization methods === diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp index 099a19ce2..0cf2dd513 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/discovery/runtime_discovery.hpp @@ -14,7 +14,6 @@ #pragma once -#include "ros2_medkit_gateway/discovery/discovery_enums.hpp" #include "ros2_medkit_gateway/discovery/discovery_strategy.hpp" #include "ros2_medkit_gateway/discovery/models/app.hpp" #include "ros2_medkit_gateway/discovery/models/area.hpp" @@ -27,7 +26,6 @@ #include #include #include -#include #include #include @@ -45,15 +43,13 @@ namespace discovery { * It discovers entities by querying the ROS 2 node graph at runtime. * * Features: - * - Discovers areas from node namespaces - * - Discovers components from ROS 2 nodes - * - Discovers topic-based "virtual" components for systems like Isaac Sim - * - Enriches components with services, actions, and topics + * - Discovers Functions from node namespaces (functional grouping) * - Exposes nodes as Apps - * - Can create synthetic Components that group Apps + * - Enriches apps with services, actions, and topics * - * @note Functions are not supported in runtime-only mode. - * Use ManifestDiscoveryStrategy for custom entity definitions. + * Note: Synthetic/heuristic Area and Component creation has been removed. + * Areas come from manifest only. Components come from HostInfoProvider + * or manifest. Namespaces create Function entities. */ class RuntimeDiscoveryStrategy : public DiscoveryStrategy { public: @@ -61,12 +57,7 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy { * @brief Runtime discovery configuration options */ struct RuntimeConfig { - bool create_synthetic_areas{true}; - bool create_synthetic_components{true}; - ComponentGroupingStrategy grouping{}; - std::string synthetic_component_name_pattern{"{area}"}; - TopicOnlyPolicy topic_only_policy{TopicOnlyPolicy::CREATE_COMPONENT}; - int min_topics_for_component{1}; + bool create_functions_from_namespaces{true}; }; /** @@ -82,22 +73,33 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy { void set_config(const RuntimeConfig & config); /// @copydoc DiscoveryStrategy::discover_areas + /// @note Always returns empty - Areas come from manifest only std::vector discover_areas() override; /// @copydoc DiscoveryStrategy::discover_components + /// @note Always returns empty - Components come from HostInfoProvider or manifest std::vector discover_components() override; - /// Discover components using pre-discovered apps (avoids redundant graph introspection) - std::vector discover_components(const std::vector & apps); - /// @copydoc DiscoveryStrategy::discover_apps /// @note Returns nodes as Apps in runtime discovery std::vector discover_apps() override; /// @copydoc DiscoveryStrategy::discover_functions - /// @note Returns empty vector - functions require manifest + /// @note Creates Function entities from namespace grouping when enabled std::vector discover_functions() override; + /** + * @brief Create Function entities from pre-discovered apps + * + * Avoids redundant ROS 2 graph introspection when apps have already been + * discovered in the same refresh cycle (e.g., refresh_cache() calls + * discover_apps() before discover_functions()). + * + * @param apps Pre-discovered apps to group by namespace + * @return Vector of Function entities + */ + std::vector discover_functions(const std::vector & apps); + /// @copydoc DiscoveryStrategy::get_name std::string get_name() const override { return "runtime"; @@ -107,28 +109,6 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy { // Runtime-specific methods (from current DiscoveryManager) // ========================================================================= - /** - * @brief Discover synthetic components (grouped by namespace) - * - * Groups runtime apps by namespace into aggregated Component entities. - * Uses provided apps to avoid re-querying the ROS 2 graph. - * - * @param apps Pre-discovered apps (from discover_apps()) - * @return Vector of synthetic components - */ - std::vector discover_synthetic_components(const std::vector & apps); - - /** - * @brief Discover components from topic namespaces (topic-based discovery) - * - * Creates "virtual" components for topic namespaces that don't have - * corresponding ROS 2 nodes. This is useful for systems like Isaac Sim - * that publish topics without creating proper ROS 2 nodes. - * - * @return Vector of topic-based components (excludes namespaces with existing nodes) - */ - std::vector discover_topic_components(); - /** * @brief Discover all services in the system with their types * @return Vector of ServiceInfo with schema information @@ -187,21 +167,12 @@ class RuntimeDiscoveryStrategy : public DiscoveryStrategy { /// Extract the last segment from a path (e.g., "/a/b/c" -> "c") std::string extract_name_from_path(const std::string & path); - /// Get set of namespaces that have ROS 2 nodes (for deduplication) - std::set get_node_namespaces(); - /// Check if a service path belongs to a component namespace bool path_belongs_to_namespace(const std::string & path, const std::string & ns) const; /// Check if a service path is an internal ROS2 service static bool is_internal_service(const std::string & service_path); - /// Derive component ID for a node based on grouping strategy - std::string derive_component_id(const std::string & node_id, const std::string & area); - - /// Apply naming pattern for synthetic component ID - std::string apply_component_name_pattern(const std::string & area); - rclcpp::Node * node_; RuntimeConfig config_; NativeTopicSampler * topic_sampler_{nullptr}; diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp index e7df62f30..7f54ece9a 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/gateway_node.hpp @@ -23,6 +23,8 @@ #include #include +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" +#include "ros2_medkit_gateway/aggregation/mdns_discovery.hpp" #include "ros2_medkit_gateway/auth/auth_config.hpp" #include "ros2_medkit_gateway/bulk_data_store.hpp" #include "ros2_medkit_gateway/condition_evaluator.hpp" @@ -174,6 +176,12 @@ class GatewayNode : public rclcpp::Node { */ ConditionRegistry * get_condition_registry() const; + /** + * @brief Get the AggregationManager instance + * @return Raw pointer to AggregationManager (valid for lifetime of GatewayNode), or nullptr if disabled + */ + AggregationManager * get_aggregation_manager() const; + private: void refresh_cache(); void start_rest_server(); @@ -183,6 +191,7 @@ class GatewayNode : public rclcpp::Node { std::string server_host_; int server_port_; int refresh_interval_ms_; + bool filter_internal_nodes_{true}; CorsConfig cors_config_; AuthConfig auth_config_; RateLimitConfig rate_limit_config_; @@ -218,6 +227,12 @@ class GatewayNode : public rclcpp::Node { std::unique_ptr trigger_fault_subscriber_; std::unique_ptr trigger_topic_subscriber_; + // Aggregation infrastructure (destroyed in order: mdns -> rest_server -> aggregation) + // mDNS threads must stop before rest_server to avoid callbacks during shutdown. + // AggregationManager must outlive rest_server because handlers reference it. + std::unique_ptr aggregation_mgr_; + std::unique_ptr mdns_discovery_; + std::unique_ptr rest_server_; // Cache with thread safety @@ -242,4 +257,19 @@ class GatewayNode : public rclcpp::Node { std::condition_variable server_cv_; }; +/** + * @brief Filter ROS 2 internal nodes from an app list + * + * Removes apps whose base name begins with '_' (ROS 2 internal node convention). + * For remote (peer-prefixed) apps, the peer prefix ("peer_name__") is stripped + * before checking for the underscore prefix, using the routing table for precise + * prefix detection. + * + * @param apps App vector to filter in place + * @param peer_routing_table Maps entity_id -> peer_name for remote entities + * @return Number of apps removed + */ +size_t filter_internal_node_apps(std::vector & apps, + const std::unordered_map & peer_routing_table); + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp index 44467b6b9..afd3473e6 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/handlers/handler_context.hpp @@ -71,6 +71,11 @@ struct EntityInfo { std::string id_field; ///< JSON field name for ID ("component_id", "app_id", etc.) std::string error_name; ///< Human-readable name for errors ("Component", "App", etc.) + // Peer aggregation fields + bool is_remote{false}; ///< True if entity was discovered from a peer gateway + std::string peer_url; ///< Base URL of the peer (e.g., "http://localhost:8081") + std::string peer_name; ///< Peer name for metadata (e.g., "subsystem_b") + /** * @brief Get SovdEntityType equivalent */ @@ -86,10 +91,28 @@ struct EntityInfo { } }; +/** + * @brief Outcome when validate_entity_for_route() rejects the request + * + * Disambiguates the two reasons a validation can fail: + * - kErrorSent: an error response (400/404) was written to `res` + * - kForwarded: the request was proxied to a peer gateway (aggregation) + * + * In both cases the handler must `return` immediately - the HTTP response + * is already committed. The distinction exists so that callers who need to + * (e.g., logging, metrics) can tell the two apart. + */ +enum class ValidationOutcome { kErrorSent, kForwarded }; + +/// Result type for validate_entity_for_route(): EntityInfo on success, +/// ValidationOutcome on failure (response already sent in both cases). +using ValidateResult = tl::expected; + // Forward declarations class GatewayNode; class AuthManager; class BulkDataStore; +class AggregationManager; namespace handlers { @@ -141,6 +164,16 @@ class HandlerContext { return bulk_data_store_; } + /// Set the aggregation manager (non-owning, null when aggregation disabled) + void set_aggregation_manager(AggregationManager * mgr) { + aggregation_mgr_ = mgr; + } + + /// Get the aggregation manager (may be nullptr if aggregation disabled) + AggregationManager * aggregation_manager() const { + return aggregation_mgr_; + } + /** * @brief Validate entity ID (component_id, area_id, etc.) * @param entity_id The ID to validate @@ -206,7 +239,8 @@ class HandlerContext { * Unified validation helper that: * 1. Validates entity ID format * 2. Looks up entity in the expected collection (based on route path) - * 3. Sends appropriate error responses: + * 3. For remote entities (aggregation), forwards the request to the peer gateway + * 4. Sends appropriate error responses on failure: * - 400 with "invalid-parameter" if ID format is invalid * - 400 with "invalid-parameter" if entity exists but wrong type for route * - 404 with "entity-not-found" if entity doesn't exist @@ -214,10 +248,13 @@ class HandlerContext { * @param req HTTP request (used to extract expected type from path) * @param res HTTP response (error responses sent here) * @param entity_id Entity ID to validate - * @return EntityInfo if valid, std::nullopt if error response was sent + * @return EntityInfo on success. On failure, returns ValidationOutcome indicating + * whether an error was sent (kErrorSent) or the request was forwarded + * to a peer (kForwarded). In both failure cases the response is already + * committed and the handler must return immediately. */ - std::optional validate_entity_for_route(const httplib::Request & req, httplib::Response & res, - const std::string & entity_id) const; + ValidateResult validate_entity_for_route(const httplib::Request & req, httplib::Response & res, + const std::string & entity_id) const; /** * @brief Set CORS headers on response if origin is allowed @@ -278,6 +315,7 @@ class HandlerContext { TlsConfig tls_config_; AuthManager * auth_manager_; BulkDataStore * bulk_data_store_; + AggregationManager * aggregation_mgr_{nullptr}; ///< Non-owning, null when aggregation disabled }; } // namespace handlers diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp index 048b8f312..a2161148e 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/http/rest_server.hpp @@ -40,6 +40,7 @@ class RouteRegistry; namespace ros2_medkit_gateway { +class AggregationManager; class GatewayNode; class TriggerManager; @@ -71,6 +72,10 @@ class RESTServer { /// Creates TriggerHandlers using the existing handler context and SSE client tracker. void set_trigger_handlers(TriggerManager & trigger_mgr); + /// Set aggregation manager on the handler context (called by GatewayNode when aggregation is enabled). + /// Must be called before start() so handlers can forward requests to peers. + void set_aggregation_manager(AggregationManager * mgr); + /// Check if TLS/HTTPS is enabled bool is_tls_enabled() const { return http_server_ && http_server_->is_tls_enabled(); diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models/aggregation_service.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models/aggregation_service.hpp index 21823211f..ec16b9e3d 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models/aggregation_service.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/models/aggregation_service.hpp @@ -104,6 +104,37 @@ class AggregationService { */ static bool should_aggregate(SovdEntityType type); + /** + * @brief Get child app IDs for an aggregated entity + * + * Resolves the leaf App entity IDs that contribute resources to + * an aggregated entity (Function, Area, or Component). + * + * - FUNCTION: returns app IDs from the function's hosts list + * - AREA: returns app IDs from all components in the area + * - COMPONENT: returns app IDs hosted by the component + * - APP: returns the app's own ID + * + * @param type Entity type + * @param entity_id Entity identifier + * @return Vector of child app IDs + */ + std::vector get_child_app_ids(SovdEntityType type, const std::string & entity_id) const; + + /** + * @brief Build x-medkit extension JSON for any aggregated collection response + * + * Creates a standardized x-medkit metadata object with: + * - aggregated: true/false + * - aggregation_sources: [...] (child app IDs) + * - aggregation_level: "app" | "component" | "area" | "function" + * + * @param type Entity type (determines aggregation_level) + * @param entity_id Entity identifier (used to resolve sources) + * @return JSON object for x-medkit field + */ + nlohmann::json build_collection_x_medkit(SovdEntityType type, const std::string & entity_id) const; + private: const ThreadSafeEntityCache * cache_; }; diff --git a/src/ros2_medkit_gateway/src/aggregation/aggregation_manager.cpp b/src/ros2_medkit_gateway/src/aggregation/aggregation_manager.cpp new file mode 100644 index 000000000..3c61187ce --- /dev/null +++ b/src/ros2_medkit_gateway/src/aggregation/aggregation_manager.cpp @@ -0,0 +1,561 @@ +// 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/aggregation/aggregation_manager.hpp" + +#include +#include +#include +#include +#include + +#include +#include + +#include + +#include "ros2_medkit_gateway/aggregation/entity_merger.hpp" +#include "ros2_medkit_gateway/http/error_codes.hpp" + +namespace ros2_medkit_gateway { + +namespace { + +/** + * @brief Extract the host portion from a URL + * + * Handles both regular hosts and IPv6 bracket notation. + * Returns empty string if the URL cannot be parsed. + */ +std::string extract_host(const std::string & url) { + // Find the start of the host (after "://") + auto scheme_end = url.find("://"); + if (scheme_end == std::string::npos) { + return ""; + } + size_t host_start = scheme_end + 3; + if (host_start >= url.size()) { + return ""; + } + + // IPv6 bracket notation: [::1] or [fe80::1%eth0] + if (url[host_start] == '[') { + auto bracket_end = url.find(']', host_start); + if (bracket_end == std::string::npos) { + return ""; + } + return url.substr(host_start + 1, bracket_end - host_start - 1); + } + + // Regular host: find end at ':', '/', or end of string + size_t host_end = url.find_first_of(":/", host_start); + if (host_end == std::string::npos) { + host_end = url.size(); + } + return url.substr(host_start, host_end - host_start); +} + +/** + * @brief Check if a resolved address is loopback, link-local, or unspecified + * + * Returns true if the address should be blocked for mDNS-discovered peers. + */ +bool is_blocked_address(const struct sockaddr * addr) { + if (addr->sa_family == AF_INET) { + const auto * sin = reinterpret_cast(addr); + uint32_t ip = ntohl(sin->sin_addr.s_addr); + // 127.0.0.0/8 - loopback + if ((ip >> 24) == 127) { + return true; + } + // 0.0.0.0 - unspecified (binds all interfaces, self-referential) + if (ip == 0) { + return true; + } + // 169.254.0.0/16 - link-local (includes cloud metadata 169.254.169.254) + if ((ip >> 16) == 0xA9FE) { + return true; + } + return false; + } + + if (addr->sa_family == AF_INET6) { + const auto * sin6 = reinterpret_cast(addr); + // ::1 - IPv6 loopback + if (IN6_IS_ADDR_LOOPBACK(&sin6->sin6_addr)) { + return true; + } + // :: - IPv6 unspecified + if (IN6_IS_ADDR_UNSPECIFIED(&sin6->sin6_addr)) { + return true; + } + // fe80::/10 - IPv6 link-local + if (IN6_IS_ADDR_LINKLOCAL(&sin6->sin6_addr)) { + return true; + } + // ::ffff:127.x.x.x - IPv4-mapped loopback + // ::ffff:0.0.0.0 - IPv4-mapped unspecified + // ::ffff:169.254.x.x - IPv4-mapped link-local + if (IN6_IS_ADDR_V4MAPPED(&sin6->sin6_addr)) { + const auto * bytes = sin6->sin6_addr.s6_addr; + // Last 4 bytes are the IPv4 address + uint32_t ip = (static_cast(bytes[12]) << 24) | (static_cast(bytes[13]) << 16) | + (static_cast(bytes[14]) << 8) | static_cast(bytes[15]); + if ((ip >> 24) == 127 || ip == 0 || (ip >> 16) == 0xA9FE) { + return true; + } + } + return false; + } + + // Unknown address family - block by default + return true; +} + +/** + * @brief Validate a peer URL discovered via mDNS + * + * Rejects URLs that don't use HTTP(S), point to cloud metadata endpoints, + * or resolve to loopback/link-local/unspecified addresses. Uses getaddrinfo() + * to resolve the host, which catches IPv4-mapped IPv6 loopback (::ffff:127.0.0.1), + * expanded IPv6 loopback (0:0:0:0:0:0:0:1), 0.0.0.0, [::], and other bypass + * variants that substring matching would miss. + */ +bool is_valid_peer_url(const std::string & url) { + // Must start with http:// or https:// + if (url.rfind("http://", 0) != 0 && url.rfind("https://", 0) != 0) { + return false; + } + + // Block well-known cloud metadata hostnames regardless of resolution + if (url.find("metadata.google") != std::string::npos) { + return false; + } + + std::string host = extract_host(url); + if (host.empty()) { + return false; + } + + // Resolve the host to check the actual address + struct addrinfo hints {}; + hints.ai_family = AF_UNSPEC; + hints.ai_socktype = SOCK_STREAM; + hints.ai_flags = AI_NUMERICHOST; // Try numeric first (fast, no DNS) + + struct addrinfo * result = nullptr; + int ret = getaddrinfo(host.c_str(), nullptr, &hints, &result); + + if (ret != 0) { + // Not a numeric address - try DNS resolution + hints.ai_flags = 0; + ret = getaddrinfo(host.c_str(), nullptr, &hints, &result); + if (ret != 0) { + // Cannot resolve - reject + return false; + } + } + + // Check all resolved addresses - block if ANY resolves to a blocked range + bool blocked = false; + for (struct addrinfo * rp = result; rp != nullptr; rp = rp->ai_next) { + if (is_blocked_address(rp->ai_addr)) { + blocked = true; + break; + } + } + + freeaddrinfo(result); + return !blocked; +} + +} // namespace + +AggregationManager::AggregationManager(const AggregationConfig & config, rclcpp::Logger * logger) + : config_(config), logger_(logger ? *logger : rclcpp::get_logger("aggregation_manager")) { + for (const auto & peer_cfg : config_.peers) { + // Validate scheme for static peers (http/https only). + // Unlike mDNS peers, loopback addresses are valid for static config + // (e.g., testing or same-host deployments). + if (peer_cfg.url.rfind("http://", 0) != 0 && peer_cfg.url.rfind("https://", 0) != 0) { + continue; + } + + // TLS enforcement: reject http:// peers when require_tls is true, + // warn about cleartext when require_tls is false. + if (peer_cfg.url.rfind("http://", 0) == 0) { + if (config_.require_tls) { + if (logger) { + RCLCPP_ERROR(*logger, "Aggregation: skipping peer '%s' at %s - require_tls is enabled but URL uses http://", + peer_cfg.name.c_str(), peer_cfg.url.c_str()); + } + continue; + } + if (logger) { + RCLCPP_WARN(*logger, "Aggregation: peer '%s' at %s uses cleartext HTTP - consider using HTTPS", + peer_cfg.name.c_str(), peer_cfg.url.c_str()); + } + } + + peers_.push_back( + std::make_shared(peer_cfg.url, peer_cfg.name, config_.timeout_ms, config_.forward_auth)); + } + static_peer_count_ = peers_.size(); +} + +size_t AggregationManager::peer_count() const { + std::shared_lock lock(mutex_); + return peers_.size(); +} + +void AggregationManager::add_discovered_peer(const std::string & url, const std::string & name) { + // Validate mDNS-discovered peer URLs (static config peers bypass this check) + if (!is_valid_peer_url(url)) { + return; + } + + // TLS enforcement for discovered peers + if (url.rfind("http://", 0) == 0 && config_.require_tls) { + return; + } + + std::unique_lock lock(mutex_); + + // Do not add if a peer with this name already exists + if (find_peer(name) != nullptr) { + return; + } + + // Enforce max_discovered_peers limit (static peers do not count) + size_t discovered_count = peers_.size() - static_peer_count_; + if (discovered_count >= config_.max_discovered_peers) { + RCLCPP_WARN(logger_, + "Aggregation: max_discovered_peers limit (%zu) reached, " + "rejecting peer '%s' at %s", + config_.max_discovered_peers, name.c_str(), url.c_str()); + return; + } + + peers_.push_back(std::make_shared(url, name, config_.timeout_ms, config_.forward_auth)); +} + +void AggregationManager::remove_discovered_peer(const std::string & name) { + std::unique_lock lock(mutex_); + + auto it = std::remove_if(peers_.begin(), peers_.end(), [&name](const std::shared_ptr & peer) { + return peer->name() == name; + }); + peers_.erase(it, peers_.end()); +} + +void AggregationManager::check_all_health() { + // Snapshot shared_ptrs under lock, then release before I/O. + // shared_ptr copies keep PeerClients alive even if remove_discovered_peer() + // erases them from peers_ during health checks. + std::vector> snapshot; + { + std::shared_lock lock(mutex_); + snapshot.reserve(peers_.size()); + for (const auto & peer : peers_) { + snapshot.push_back(peer); + } + } + + // Health checks run in parallel via std::async to reduce worst-case latency + // from N * timeout_ms (sequential) to just timeout_ms (parallel). + std::vector> futures; + futures.reserve(snapshot.size()); + for (auto & peer : snapshot) { + futures.push_back(std::async(std::launch::async, [peer]() { + peer->check_health(); + })); + } + for (auto & f : futures) { + f.get(); + } +} + +size_t AggregationManager::healthy_peer_count() const { + std::shared_lock lock(mutex_); + + size_t count = 0; + for (const auto & peer : peers_) { + if (peer->is_healthy()) { + ++count; + } + } + return count; +} + +PeerEntities AggregationManager::fetch_all_peer_entities() { + // Snapshot healthy peers under lock, release before network I/O. + std::vector> snapshot; + { + std::shared_lock lock(mutex_); + for (const auto & peer : peers_) { + if (peer->is_healthy()) { + snapshot.push_back(peer); + } + } + } + + PeerEntities merged; + for (auto & peer : snapshot) { + auto result = peer->fetch_entities(); + if (!result.has_value()) { + continue; + } + + const auto & entities = result.value(); + merged.areas.insert(merged.areas.end(), entities.areas.begin(), entities.areas.end()); + merged.components.insert(merged.components.end(), entities.components.begin(), entities.components.end()); + merged.apps.insert(merged.apps.end(), entities.apps.begin(), entities.apps.end()); + merged.functions.insert(merged.functions.end(), entities.functions.begin(), entities.functions.end()); + } + + return merged; +} + +AggregationManager::MergedPeerResult AggregationManager::fetch_and_merge_peer_entities( + const std::vector & local_areas, const std::vector & local_components, + const std::vector & local_apps, const std::vector & local_functions, size_t max_entities_per_peer, + rclcpp::Logger * logger) { + MergedPeerResult merged; + merged.areas = local_areas; + merged.components = local_components; + merged.apps = local_apps; + merged.functions = local_functions; + + // Snapshot healthy peers under lock, release before network I/O. + // shared_ptr copies keep PeerClients alive even if remove_discovered_peer() + // erases them from peers_ concurrently. + std::vector> snapshot; + { + std::shared_lock lock(mutex_); + for (const auto & peer : peers_) { + if (peer->is_healthy()) { + snapshot.push_back(peer); + } + } + } + + for (auto & peer : snapshot) { + auto result = peer->fetch_entities(); + if (!result.has_value()) { + if (logger) { + RCLCPP_WARN(*logger, "Failed to fetch entities from peer '%s': %s", peer->name().c_str(), + result.error().c_str()); + } + continue; + } + + size_t total = result->areas.size() + result->components.size() + result->apps.size() + result->functions.size(); + if (total > max_entities_per_peer) { + if (logger) { + RCLCPP_WARN(*logger, "Peer '%s' returned %zu entities (max %zu), skipping", peer->name().c_str(), total, + max_entities_per_peer); + } + continue; + } + + EntityMerger merger(peer->name()); + merged.areas = merger.merge_areas(merged.areas, result->areas); + merged.functions = merger.merge_functions(merged.functions, result->functions); + merged.components = merger.merge_components(merged.components, result->components); + merged.apps = merger.merge_apps(merged.apps, result->apps); + + for (const auto & [id, name] : merger.get_routing_table()) { + merged.routing_table[id] = name; + // Log collision-prefixed entities to help operators diagnose naming conflicts + if (id.find(EntityMerger::SEPARATOR) != std::string::npos) { + if (logger) { + RCLCPP_WARN(*logger, "Entity ID collision: '%s' prefixed for peer '%s'", id.c_str(), name.c_str()); + } + } + } + } + + return merged; +} + +void AggregationManager::update_routing_table(const std::unordered_map & table) { + std::unique_lock lock(mutex_); + routing_table_ = table; +} + +std::optional AggregationManager::find_peer_for_entity(const std::string & entity_id) const { + std::shared_lock lock(mutex_); + auto it = routing_table_.find(entity_id); + if (it != routing_table_.end()) { + return it->second; + } + return std::nullopt; +} + +std::string AggregationManager::get_peer_url(const std::string & peer_name) const { + std::shared_lock lock(mutex_); + + const auto * peer = find_peer(peer_name); + if (peer != nullptr) { + return peer->url(); + } + return ""; +} + +void AggregationManager::forward_request(const std::string & peer_name, const httplib::Request & req, + httplib::Response & res) { + // Find peer under lock, take shared_ptr copy for lifetime safety, then release + // before network I/O. The shared_ptr keeps the PeerClient alive even if + // remove_discovered_peer() erases it from peers_ concurrently. + std::shared_ptr peer; + { + std::shared_lock lock(mutex_); + peer = find_peer_shared(peer_name); + } + + if (!peer) { + res.status = 502; + nlohmann::json error_body; + error_body["error_code"] = ERR_VENDOR_ERROR; + error_body["vendor_code"] = "x-medkit-peer-unavailable"; + error_body["message"] = "Peer '" + peer_name + "' is not known to this gateway"; + res.set_content(error_body.dump(), "application/json"); + return; + } + + // Validate forwarded path - only allow SOVD API paths to prevent SSRF + // to internal peer endpoints (e.g., /metrics, /debug, /admin). + if (req.path.rfind("/api/v1/", 0) != 0) { + res.status = 400; + nlohmann::json error_body; + error_body["error_code"] = ERR_INVALID_REQUEST; + error_body["message"] = "Forwarded request path must start with /api/v1/"; + res.set_content(error_body.dump(), "application/json"); + return; + } + + // Strip peer prefix from entity ID in the path if present. + // When entity ID collision causes renaming (e.g., camera_driver -> peer_b__camera_driver), + // the peer only knows the entity by its original ID (camera_driver), so we must strip + // the prefix before forwarding. + // Anchor to path segment boundary: the prefix must appear right after '/' to avoid + // false matches inside other path segments (e.g., "v1" matching "/api/v1/"). + std::string forwarded_path = req.path; + std::string prefix = peer_name + EntityMerger::SEPARATOR; + auto prefix_pos = forwarded_path.find(prefix); + if (prefix_pos != std::string::npos && prefix_pos > 0 && forwarded_path[prefix_pos - 1] == '/') { + forwarded_path.erase(prefix_pos, prefix.size()); + } + + httplib::Request modified_req = req; + modified_req.path = forwarded_path; + + peer->forward_request(modified_req, res); +} + +AggregationManager::FanOutResult AggregationManager::fan_out_get(const std::string & path, + const std::string & auth_header) { + // Per-peer result collected by each async task + struct PeerResult { + std::string peer_name; + bool success{false}; + nlohmann::json items = nlohmann::json::array(); + }; + + // Snapshot healthy peers under lock, release before network I/O. + std::vector> snapshot; + { + std::shared_lock lock(mutex_); + for (const auto & peer : peers_) { + if (peer->is_healthy()) { + snapshot.push_back(peer); + } + } + } + + // Fan out GET requests in parallel via std::async to reduce worst-case + // latency from N * timeout_ms (sequential) to just timeout_ms (parallel). + std::vector> futures; + futures.reserve(snapshot.size()); + for (auto & peer : snapshot) { + futures.push_back(std::async(std::launch::async, [peer, &path, &auth_header]() { + PeerResult pr; + pr.peer_name = peer->name(); + + auto result = peer->forward_and_get_json("GET", path, auth_header); + if (!result.has_value()) { + pr.success = false; + return pr; + } + + pr.success = true; + const auto & response_json = result.value(); + if (response_json.contains("items") && response_json["items"].is_array()) { + pr.items = response_json["items"]; + } + return pr; + })); + } + + // Merge results from all peers + FanOutResult fan_out_result; + fan_out_result.merged_items = nlohmann::json::array(); + for (auto & f : futures) { + auto pr = f.get(); + if (!pr.success) { + fan_out_result.is_partial = true; + fan_out_result.failed_peers.push_back(std::move(pr.peer_name)); + continue; + } + for (auto & item : pr.items) { + fan_out_result.merged_items.push_back(std::move(item)); + } + } + + return fan_out_result; +} + +nlohmann::json AggregationManager::get_peer_status() const { + std::shared_lock lock(mutex_); + + nlohmann::json status_array = nlohmann::json::array(); + for (const auto & peer : peers_) { + nlohmann::json peer_obj; + peer_obj["name"] = peer->name(); + peer_obj["url"] = peer->url(); + peer_obj["status"] = peer->is_healthy() ? "online" : "offline"; + status_array.push_back(peer_obj); + } + return status_array; +} + +PeerClient * AggregationManager::find_peer(const std::string & name) const { + for (const auto & peer : peers_) { + if (peer->name() == name) { + return peer.get(); + } + } + return nullptr; +} + +std::shared_ptr AggregationManager::find_peer_shared(const std::string & name) const { + for (const auto & peer : peers_) { + if (peer->name() == name) { + return peer; + } + } + return nullptr; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/aggregation/entity_merger.cpp b/src/ros2_medkit_gateway/src/aggregation/entity_merger.cpp new file mode 100644 index 000000000..9528a18b3 --- /dev/null +++ b/src/ros2_medkit_gateway/src/aggregation/entity_merger.cpp @@ -0,0 +1,202 @@ +// 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/aggregation/entity_merger.hpp" + +#include + +namespace ros2_medkit_gateway { + +EntityMerger::EntityMerger(const std::string & peer_name) : peer_name_(peer_name) { +} + +std::string EntityMerger::prefix_id(const std::string & id) const { + return peer_name_ + SEPARATOR + id; +} + +std::string EntityMerger::peer_source() const { + return "peer:" + peer_name_; +} + +const std::unordered_map & EntityMerger::get_routing_table() const { + return routing_table_; +} + +std::vector EntityMerger::merge_areas(const std::vector & local, const std::vector & remote) { + // Start with copies of all local areas + std::vector result = local; + + // Build index of local area IDs for fast lookup + std::unordered_map local_index; + for (size_t i = 0; i < result.size(); ++i) { + local_index[result[i].id] = i; + } + + for (const auto & remote_area : remote) { + auto it = local_index.find(remote_area.id); + if (it != local_index.end()) { + // Collision: merge by combining tags (no duplication) + auto & merged = result[it->second]; + + // Merge tags without duplicates + std::unordered_set tag_set(merged.tags.begin(), merged.tags.end()); + for (const auto & tag : remote_area.tags) { + if (tag_set.insert(tag).second) { + merged.tags.push_back(tag); + } + } + + // If local has no description but remote does, take remote's + if (merged.description.empty() && !remote_area.description.empty()) { + merged.description = remote_area.description; + } + + // Merged areas do NOT go into routing table - they are combined local+remote + } else { + // No collision: add remote area with source tagged + Area added = remote_area; + added.source = peer_source(); + result.push_back(added); + + // Remote-only areas get a routing entry + routing_table_[added.id] = peer_name_; + } + } + + return result; +} + +std::vector EntityMerger::merge_functions(const std::vector & local, + const std::vector & remote) { + // Start with copies of all local functions + std::vector result = local; + + // Build index of local function IDs for fast lookup + std::unordered_map local_index; + for (size_t i = 0; i < result.size(); ++i) { + local_index[result[i].id] = i; + } + + for (const auto & remote_func : remote) { + auto it = local_index.find(remote_func.id); + if (it != local_index.end()) { + // Collision: merge by combining hosts lists + auto & merged = result[it->second]; + + std::unordered_set host_set(merged.hosts.begin(), merged.hosts.end()); + for (const auto & host : remote_func.hosts) { + if (host_set.insert(host).second) { + merged.hosts.push_back(host); + } + } + + // Merge tags without duplicates + std::unordered_set tag_set(merged.tags.begin(), merged.tags.end()); + for (const auto & tag : remote_func.tags) { + if (tag_set.insert(tag).second) { + merged.tags.push_back(tag); + } + } + + // Merged functions do NOT go into routing table + } else { + // No collision: add remote function with source tagged + Function added = remote_func; + added.source = peer_source(); + result.push_back(added); + + // Remote-only functions get a routing entry + routing_table_[added.id] = peer_name_; + } + } + + return result; +} + +std::vector EntityMerger::merge_components(const std::vector & local, + const std::vector & remote) { + // Start with copies of all local components + std::vector result = local; + + // Build index of local component IDs for fast lookup + std::unordered_map local_index; + for (size_t i = 0; i < result.size(); ++i) { + local_index[result[i].id] = i; + } + + for (const auto & remote_comp : remote) { + auto it = local_index.find(remote_comp.id); + if (it != local_index.end()) { + // Collision: merge by combining tags and subcomponent relationships + auto & merged = result[it->second]; + + // Merge tags without duplicates + std::unordered_set tag_set(merged.tags.begin(), merged.tags.end()); + for (const auto & tag : remote_comp.tags) { + if (tag_set.insert(tag).second) { + merged.tags.push_back(tag); + } + } + + // If local has no description but remote does, take remote's + if (merged.description.empty() && !remote_comp.description.empty()) { + merged.description = remote_comp.description; + } + + // Merged components do NOT go into routing table - they are combined local+remote + } else { + // No collision: add remote component with source tagged + Component added = remote_comp; + added.source = peer_source(); + result.push_back(added); + + // Remote-only components get a routing entry + routing_table_[added.id] = peer_name_; + } + } + + return result; +} + +std::vector EntityMerger::merge_apps(const std::vector & local, const std::vector & remote) { + // Start with copies of all local apps + std::vector result = local; + + // Build set of local app IDs + std::unordered_set local_ids; + for (const auto & app : local) { + local_ids.insert(app.id); + } + + for (const auto & remote_app : remote) { + App added = remote_app; + added.source = peer_source(); + // Remap component_id to peer name - in the aggregation model, the peer + // IS a component/subcomponent and all its apps belong to it. + added.component_id = peer_name_; + + if (local_ids.count(remote_app.id) > 0) { + // Collision: prefix the remote entity ID + added.id = prefix_id(remote_app.id); + added.name = peer_name_ + SEPARATOR + remote_app.name; + } + + routing_table_[added.id] = peer_name_; + result.push_back(added); + } + + return result; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/aggregation/mdns_discovery.cpp b/src/ros2_medkit_gateway/src/aggregation/mdns_discovery.cpp new file mode 100644 index 000000000..8beca86ee --- /dev/null +++ b/src/ros2_medkit_gateway/src/aggregation/mdns_discovery.cpp @@ -0,0 +1,552 @@ +// 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. + +// Define MDNS_IMPLEMENTATION in exactly one translation unit to get the +// implementation of the mjansson/mdns header-only C library. +#define MDNS_IMPLEMENTATION + +// Suppress compiler warnings for vendored C header +#pragma GCC diagnostic push +#pragma GCC diagnostic ignored "-Wconversion" +#pragma GCC diagnostic ignored "-Wsign-conversion" +#pragma GCC diagnostic ignored "-Wold-style-cast" +// The mdns.h header is C code with its own extern "C" guards +#include "mdns.h" +#pragma GCC diagnostic pop + +#include "ros2_medkit_gateway/aggregation/mdns_discovery.hpp" + +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/discovery/host_info_provider.hpp" + +namespace ros2_medkit_gateway { + +namespace { + +// Buffer size for mDNS packet assembly/parsing (must be 32-bit aligned) +constexpr size_t kMdnsBufferSize = 2048; + +// How often the browse thread sends queries (seconds) +constexpr int kBrowseIntervalSec = 10; + +// Poll interval for socket reads (milliseconds) +constexpr int kPollIntervalMs = 500; + +/** + * @brief Context passed to mDNS browse callback via user_data pointer + */ +struct BrowseContext { + MdnsDiscovery::PeerFoundCallback * on_found; + MdnsDiscovery::LogCallback * on_log; + std::string service_name; + std::string peer_scheme; +}; + +/** + * @brief Context passed to mDNS announce callback via user_data pointer + */ +struct AnnounceContext { + MdnsDiscovery::Config * config; + MdnsDiscovery::LogCallback * on_log; +}; + +/** + * @brief Get the local hostname + * @return Hostname string, or "unknown" on failure + */ +std::string get_hostname() { + std::array buf{}; + if (gethostname(buf.data(), buf.size()) == 0) { + return std::string(buf.data()); + } + return "unknown"; +} + +/** + * @brief mDNS callback for browse/query responses + * + * Called by mdns_query_recv() for each record in a response. We look for + * SRV records that match our service type and extract the hostname and port + * to build a peer URL. + */ +int browse_callback(int /*sock*/, const struct sockaddr * from, size_t addrlen, mdns_entry_type_t entry, + uint16_t /*query_id*/, uint16_t rtype, uint16_t /*rclass*/, uint32_t /*ttl*/, const void * data, + size_t size, size_t name_offset, size_t /*name_length*/, size_t record_offset, size_t record_length, + void * user_data) { + auto * ctx = static_cast(user_data); + + // We only care about answer records with SRV type + if (entry != MDNS_ENTRYTYPE_ANSWER && entry != MDNS_ENTRYTYPE_ADDITIONAL) { + return 0; + } + + if (rtype != MDNS_RECORDTYPE_SRV) { + if (ctx->on_log && *ctx->on_log) { + (*ctx->on_log)("browse: ignoring record type " + std::to_string(rtype) + + " (want SRV=" + std::to_string(MDNS_RECORDTYPE_SRV) + ")"); + } + return 0; + } + + std::array name_buf{}; + mdns_record_srv_t srv = + mdns_record_parse_srv(data, size, record_offset, record_length, name_buf.data(), name_buf.size()); + + // Extract the instance name from the record name + std::array entry_name_buf{}; + size_t name_off = name_offset; + mdns_string_t entry_name = mdns_string_extract(data, size, &name_off, entry_name_buf.data(), entry_name_buf.size()); + + std::string instance_name(entry_name.str, entry_name.length); + std::string target(srv.name.str, srv.name.length); + + // Build peer URL from the source address + std::array addr_buf{}; + std::string addr_str; + + if (from->sa_family == AF_INET) { + const auto * addr4 = reinterpret_cast(from); + inet_ntop(AF_INET, &addr4->sin_addr, addr_buf.data(), addr_buf.size()); + addr_str = addr_buf.data(); + } else if (from->sa_family == AF_INET6) { + const auto * addr6 = reinterpret_cast(from); + inet_ntop(AF_INET6, &addr6->sin6_addr, addr_buf.data(), addr_buf.size()); + addr_str = "[" + std::string(addr_buf.data()) + "]"; + } else { + (void)addrlen; // Suppress unused parameter warning + return 0; + } + (void)addrlen; // Suppress unused parameter warning in non-early-return paths + + std::string url = ctx->peer_scheme + "://" + addr_str + ":" + std::to_string(srv.port); + + // Use the instance name (before the service type) as the peer name + // Instance names look like "gateway-name._medkit._tcp.local" + std::string peer_name = instance_name; + auto dot_pos = peer_name.find('.'); + if (dot_pos != std::string::npos) { + peer_name = peer_name.substr(0, dot_pos); + } + + // Sanitize peer name to valid entity ID characters + peer_name = HostInfoProvider::sanitize_entity_id(peer_name); + if (peer_name.empty()) { + if (ctx->on_log && *ctx->on_log) { + (*ctx->on_log)("browse: peer name empty after sanitization, instance='" + instance_name + "'"); + } + return 0; + } + + if (ctx->on_log && *ctx->on_log) { + (*ctx->on_log)("browse: discovered peer '" + peer_name + "' at " + url + " (instance='" + instance_name + + "', target='" + target + "', port=" + std::to_string(srv.port) + ")"); + } + + if (ctx->on_found) { + (*ctx->on_found)(url, peer_name); + } + + return 0; +} + +/** + * @brief mDNS callback for announce/listen - responds to queries for our service + */ +int announce_callback(int sock, const struct sockaddr * from, size_t addrlen, mdns_entry_type_t entry, + uint16_t query_id, uint16_t rtype, uint16_t rclass, uint32_t /*ttl*/, const void * data, + size_t size, size_t name_offset, size_t /*name_length*/, size_t /*record_offset*/, + size_t /*record_length*/, void * user_data) { + auto * ctx = static_cast(user_data); + auto * config = ctx->config; + + // We only care about incoming questions + if (entry != MDNS_ENTRYTYPE_QUESTION) { + if (ctx->on_log && *ctx->on_log) { + (*ctx->on_log)("announce: ignoring non-question entry type " + std::to_string(static_cast(entry))); + } + return 0; + } + + // Extract the queried name + std::array name_buf{}; + size_t name_off = name_offset; + mdns_string_t name = mdns_string_extract(data, size, &name_off, name_buf.data(), name_buf.size()); + std::string queried_name(name.str, name.length); + + // Check if the query matches our service type (case-insensitive per RFC 1035) + std::string queried_lower = queried_name; + std::string service_lower = config->service; + std::transform(queried_lower.begin(), queried_lower.end(), queried_lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + std::transform(service_lower.begin(), service_lower.end(), service_lower.begin(), [](unsigned char c) { + return static_cast(std::tolower(c)); + }); + if (queried_lower.find(service_lower) == std::string::npos && rtype != MDNS_RECORDTYPE_ANY) { + if (ctx->on_log && *ctx->on_log) { + (*ctx->on_log)("announce: query name '" + queried_name + "' (rtype=" + std::to_string(rtype) + + ") does not match service '" + config->service + "', ignoring"); + } + return 0; + } + + if (ctx->on_log && *ctx->on_log) { + std::array from_buf{}; + std::string from_str = "unknown"; + if (from->sa_family == AF_INET) { + const auto * addr4 = reinterpret_cast(from); + inet_ntop(AF_INET, &addr4->sin_addr, from_buf.data(), from_buf.size()); + from_str = std::string(from_buf.data()) + ":" + std::to_string(ntohs(addr4->sin_port)); + } + bool unicast = (rclass & MDNS_UNICAST_RESPONSE) != 0; + (*ctx->on_log)("announce: received query for '" + queried_name + "' (rtype=" + std::to_string(rtype) + + ", unicast=" + (unicast ? "true" : "false") + ") from " + from_str + ", sending response"); + } + + // Build the response per DNS-SD (RFC 6763): + // - Answer: PTR record (service type -> instance name) + // - Additional: SRV record (instance name -> host:port) + std::string hostname = get_hostname(); + std::string instance = config->name + "." + config->service; + + // PTR answer: maps service type to our instance name + mdns_record_t answer{}; + answer.name.str = config->service.c_str(); + answer.name.length = config->service.size(); + answer.type = MDNS_RECORDTYPE_PTR; + answer.data.ptr.name.str = instance.c_str(); + answer.data.ptr.name.length = instance.size(); + answer.rclass = 0; + answer.ttl = 120; + + // SRV additional: maps instance name to hostname and port + mdns_record_t additional{}; + additional.name.str = instance.c_str(); + additional.name.length = instance.size(); + additional.type = MDNS_RECORDTYPE_SRV; + additional.data.srv.priority = 0; + additional.data.srv.weight = 0; + additional.data.srv.port = static_cast(config->port); + additional.data.srv.name.str = hostname.c_str(); + additional.data.srv.name.length = hostname.size(); + additional.rclass = 0; + additional.ttl = 120; + + // Send the response + std::array buffer{}; + bool unicast = (rclass & MDNS_UNICAST_RESPONSE) != 0; + + int send_result = 0; + if (unicast) { + send_result = mdns_query_answer_unicast(sock, from, addrlen, buffer.data(), buffer.size(), query_id, + static_cast(rtype), name.str, name.length, answer, + nullptr, 0, &additional, 1); + } else { + send_result = mdns_query_answer_multicast(sock, buffer.data(), buffer.size(), answer, nullptr, 0, &additional, 1); + } + + if (ctx->on_log && *ctx->on_log) { + if (send_result < 0) { + (*ctx->on_log)("announce: FAILED to send " + std::string(unicast ? "unicast" : "multicast") + + " response (result=" + std::to_string(send_result) + ", errno=" + std::to_string(errno) + ")"); + } else { + (*ctx->on_log)("announce: sent " + std::string(unicast ? "unicast" : "multicast") + " response for instance '" + + instance + "' (port " + std::to_string(config->port) + ")"); + } + } + + return 0; +} + +} // namespace + +MdnsDiscovery::MdnsDiscovery(const Config & config) : config_(config) { + if (config_.name.empty()) { + config_.name = get_hostname(); + } +} + +MdnsDiscovery::~MdnsDiscovery() { + stop(); +} + +void MdnsDiscovery::start(PeerFoundCallback on_found, PeerRemovedCallback on_removed) { + if (running_.load()) { + return; + } + + on_found_ = std::move(on_found); + on_removed_ = std::move(on_removed); + running_.store(true); + + if (config_.announce) { + announce_thread_ = std::thread([this]() { + announce_loop(); + }); + } + + if (config_.discover) { + browse_thread_ = std::thread([this]() { + browse_loop(); + }); + } +} + +void MdnsDiscovery::stop() { + if (!running_.load()) { + return; + } + + running_.store(false); + + if (announce_thread_.joinable()) { + announce_thread_.join(); + } + announcing_.store(false); + + if (browse_thread_.joinable()) { + browse_thread_.join(); + } + discovering_.store(false); +} + +bool MdnsDiscovery::is_announcing() const { + return announcing_.load(); +} + +bool MdnsDiscovery::is_discovering() const { + return discovering_.load(); +} + +void MdnsDiscovery::announce_loop() { + // Open a socket on the mDNS port (5353) to listen for queries + struct sockaddr_in saddr {}; + saddr.sin_family = AF_INET; + saddr.sin_addr.s_addr = INADDR_ANY; + saddr.sin_port = htons(MDNS_PORT); + + int sock = mdns_socket_open_ipv4(&saddr); + if (sock < 0) { + if (config_.on_error) { + config_.on_error( + "Failed to open mDNS announce socket on port 5353. " + "Check permissions (CAP_NET_BIND_SERVICE) or use static peers."); + } + return; + } + + announcing_.store(true); + + if (config_.on_log) { + config_.on_log("announce_loop: socket opened on port 5353 (fd=" + std::to_string(sock) + ", name='" + config_.name + + "', service='" + config_.service + "')"); + } + + // Send an initial announcement + { + std::string hostname = get_hostname(); + std::string instance = config_.name + "." + config_.service; + + mdns_record_t answer{}; + answer.name.str = instance.c_str(); + answer.name.length = instance.size(); + answer.type = MDNS_RECORDTYPE_SRV; + answer.data.srv.priority = 0; + answer.data.srv.weight = 0; + answer.data.srv.port = static_cast(config_.port); + answer.data.srv.name.str = hostname.c_str(); + answer.data.srv.name.length = hostname.size(); + answer.rclass = 0; + answer.ttl = 120; + + std::array buffer{}; + int ann_result = mdns_announce_multicast(sock, buffer.data(), buffer.size(), answer, nullptr, 0, nullptr, 0); + if (config_.on_log) { + if (ann_result < 0) { + config_.on_log("announce_loop: initial announcement FAILED (result=" + std::to_string(ann_result) + + ", errno=" + std::to_string(errno) + ")"); + } else { + config_.on_log("announce_loop: initial announcement sent for '" + instance + "'"); + } + } + } + + // Listen for incoming queries and respond + AnnounceContext ctx; + ctx.config = &config_; + ctx.on_log = &config_.on_log; + + std::array buffer{}; + size_t loop_count = 0; + while (running_.load()) { + struct timeval tv {}; + tv.tv_sec = 0; + tv.tv_usec = kPollIntervalMs * 1000; + + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(sock, &readfds); + + int ret = select(sock + 1, &readfds, nullptr, nullptr, &tv); + if (ret < 0) { + if (config_.on_log) { + config_.on_log("announce_loop: select() error (errno=" + std::to_string(errno) + ")"); + } + break; + } + if (ret > 0) { + if (config_.on_log) { + config_.on_log("announce_loop: select() returned data, calling mdns_socket_listen"); + } + size_t records = mdns_socket_listen(sock, buffer.data(), buffer.size(), announce_callback, &ctx); + if (config_.on_log) { + config_.on_log("announce_loop: mdns_socket_listen processed " + std::to_string(records) + " records"); + } + } + + ++loop_count; + // Log heartbeat every ~30 seconds (60 iterations * 500ms) + if (config_.on_log && (loop_count % 60) == 0) { + config_.on_log("announce_loop: alive, " + std::to_string(loop_count) + " iterations"); + } + } + + if (config_.on_log) { + config_.on_log("announce_loop: exiting (running=" + std::string(running_.load() ? "true" : "false") + ")"); + } + + // Send goodbye announcement before closing + { + std::string hostname = get_hostname(); + std::string instance = config_.name + "." + config_.service; + + mdns_record_t answer{}; + answer.name.str = instance.c_str(); + answer.name.length = instance.size(); + answer.type = MDNS_RECORDTYPE_SRV; + answer.data.srv.priority = 0; + answer.data.srv.weight = 0; + answer.data.srv.port = static_cast(config_.port); + answer.data.srv.name.str = hostname.c_str(); + answer.data.srv.name.length = hostname.size(); + answer.rclass = 0; + answer.ttl = 0; // TTL=0 for goodbye + + std::array buf{}; + mdns_goodbye_multicast(sock, buf.data(), buf.size(), answer, nullptr, 0, nullptr, 0); + } + + mdns_socket_close(sock); + announcing_.store(false); +} + +void MdnsDiscovery::browse_loop() { + // Open a socket on an ephemeral port for sending queries + int sock = mdns_socket_open_ipv4(nullptr); + if (sock < 0) { + if (config_.on_error) { + config_.on_error( + "Failed to open mDNS browse socket. " + "Check network availability or use static peers."); + } + return; + } + + discovering_.store(true); + + if (config_.on_log) { + config_.on_log("browse_loop: socket opened on ephemeral port (fd=" + std::to_string(sock) + ")"); + } + + BrowseContext ctx; + ctx.on_found = &on_found_; + ctx.on_log = &config_.on_log; + ctx.service_name = config_.service; + ctx.peer_scheme = config_.peer_scheme; + + std::array buffer{}; + auto last_query = std::chrono::steady_clock::now() - std::chrono::seconds(kBrowseIntervalSec); + + size_t loop_count = 0; + while (running_.load()) { + auto now = std::chrono::steady_clock::now(); + auto elapsed = std::chrono::duration_cast(now - last_query).count(); + + // Send a query periodically + if (elapsed >= kBrowseIntervalSec) { + int query_result = mdns_query_send(sock, MDNS_RECORDTYPE_PTR, config_.service.c_str(), config_.service.size(), + buffer.data(), buffer.size(), 0); + if (config_.on_log) { + if (query_result < 0) { + config_.on_log("browse_loop: query_send FAILED (result=" + std::to_string(query_result) + + ", errno=" + std::to_string(errno) + ")"); + } else { + config_.on_log("browse_loop: sent PTR query for '" + config_.service + "'"); + } + } + last_query = now; + } + + // Check for responses with a short timeout + struct timeval tv {}; + tv.tv_sec = 0; + tv.tv_usec = kPollIntervalMs * 1000; + + fd_set readfds; + FD_ZERO(&readfds); + FD_SET(sock, &readfds); + + int ret = select(sock + 1, &readfds, nullptr, nullptr, &tv); + if (ret < 0) { + if (config_.on_log) { + config_.on_log("browse_loop: select() error (errno=" + std::to_string(errno) + ")"); + } + break; + } + if (ret > 0) { + if (config_.on_log) { + config_.on_log("browse_loop: select() returned data, calling mdns_query_recv"); + } + size_t records = mdns_query_recv(sock, buffer.data(), buffer.size(), browse_callback, &ctx, 0); + if (config_.on_log) { + config_.on_log("browse_loop: mdns_query_recv processed " + std::to_string(records) + " records"); + } + } + + ++loop_count; + if (config_.on_log && (loop_count % 60) == 0) { + config_.on_log("browse_loop: alive, " + std::to_string(loop_count) + " iterations"); + } + } + + if (config_.on_log) { + config_.on_log("browse_loop: exiting"); + } + + mdns_socket_close(sock); + discovering_.store(false); +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/aggregation/peer_client.cpp b/src/ros2_medkit_gateway/src/aggregation/peer_client.cpp new file mode 100644 index 000000000..0be8dba78 --- /dev/null +++ b/src/ros2_medkit_gateway/src/aggregation/peer_client.cpp @@ -0,0 +1,483 @@ +// 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/aggregation/peer_client.hpp" + +#include +#include +#include +#include + +#include "ros2_medkit_gateway/http/error_codes.hpp" + +namespace ros2_medkit_gateway { + +namespace { + +/// Vendor error code for peer unavailable (connection failure) +constexpr const char * ERR_X_MEDKIT_PEER_UNAVAILABLE = "x-medkit-peer-unavailable"; + +/// API prefix for SOVD endpoints +constexpr const char * API_PREFIX = "/api/v1"; + +/// Maximum response body size to accept from peers (10MB) +constexpr size_t MAX_PEER_RESPONSE_SIZE = 10 * 1024 * 1024; + +/** + * @brief Build a SOVD GenericError JSON body + */ +nlohmann::json make_error_body(const std::string & error_code, const std::string & message, + const std::string & vendor_code = "") { + nlohmann::json body; + if (!vendor_code.empty()) { + body["error_code"] = ERR_VENDOR_ERROR; + body["vendor_code"] = vendor_code; + } else { + body["error_code"] = error_code; + } + body["message"] = message; + return body; +} + +/** + * @brief Parse an entity collection from a JSON response + * + * Expects the response to contain an "items" array. Each item is parsed + * by the provided parser function. + */ +template +std::vector parse_collection(const nlohmann::json & response_json, Parser parser) { + std::vector result; + if (response_json.contains("items") && response_json["items"].is_array()) { + for (const auto & item : response_json["items"]) { + result.push_back(parser(item)); + } + } + return result; +} + +/** + * @brief Parse an Area from JSON + */ +Area parse_area(const nlohmann::json & j) { + Area area; + area.id = j.value("id", ""); + area.name = j.value("name", ""); + if (j.contains("x-medkit") && j["x-medkit"].is_object()) { + const auto & xm = j["x-medkit"]; + area.namespace_path = xm.value("namespace", ""); + area.description = xm.value("description", ""); + area.source = xm.value("source", ""); + } + if (j.contains("translationId")) { + area.translation_id = j["translationId"].get(); + } + if (j.contains("tags") && j["tags"].is_array()) { + area.tags = j["tags"].get>(); + } + return area; +} + +/** + * @brief Parse a Component from JSON + */ +Component parse_component(const nlohmann::json & j) { + Component comp; + comp.id = j.value("id", ""); + comp.name = j.value("name", ""); + if (j.contains("x-medkit") && j["x-medkit"].is_object()) { + const auto & xm = j["x-medkit"]; + comp.namespace_path = xm.value("namespace", ""); + comp.fqn = xm.value("fqn", ""); + comp.area = xm.value("area", ""); + comp.source = xm.value("source", ""); + comp.description = xm.value("description", ""); + comp.variant = xm.value("variant", ""); + comp.parent_component_id = xm.value("parentComponentId", ""); + if (xm.contains("dependsOn") && xm["dependsOn"].is_array()) { + comp.depends_on = xm["dependsOn"].get>(); + } + } + if (j.contains("translationId")) { + comp.translation_id = j["translationId"].get(); + } + if (j.contains("tags") && j["tags"].is_array()) { + comp.tags = j["tags"].get>(); + } + return comp; +} + +/** + * @brief Parse an App from JSON + */ +App parse_app(const nlohmann::json & j) { + App app; + app.id = j.value("id", ""); + app.name = j.value("name", ""); + app.description = j.value("description", ""); + if (j.contains("x-medkit") && j["x-medkit"].is_object()) { + const auto & xm = j["x-medkit"]; + app.component_id = xm.value("component_id", ""); + app.source = xm.value("source", ""); + app.is_online = xm.value("is_online", false); + if (app.description.empty()) { + app.description = xm.value("description", ""); + } + } + if (j.contains("translationId")) { + app.translation_id = j["translationId"].get(); + } + if (j.contains("tags") && j["tags"].is_array()) { + app.tags = j["tags"].get>(); + } + return app; +} + +/** + * @brief Parse a Function from JSON + */ +Function parse_function(const nlohmann::json & j) { + Function func; + func.id = j.value("id", ""); + func.name = j.value("name", ""); + if (j.contains("x-medkit") && j["x-medkit"].is_object()) { + const auto & xm = j["x-medkit"]; + func.source = xm.value("source", ""); + func.description = xm.value("description", ""); + if (xm.contains("hosts") && xm["hosts"].is_array()) { + func.hosts = xm["hosts"].get>(); + } + } + if (j.contains("translationId")) { + func.translation_id = j["translationId"].get(); + } + if (j.contains("tags") && j["tags"].is_array()) { + func.tags = j["tags"].get>(); + } + return func; +} + +} // namespace + +PeerClient::PeerClient(const std::string & url, const std::string & name, int timeout_ms, bool forward_auth) + : url_(url), name_(name), timeout_ms_(timeout_ms), forward_auth_(forward_auth) { +} + +const std::string & PeerClient::url() const { + return url_; +} + +const std::string & PeerClient::name() const { + return name_; +} + +bool PeerClient::is_healthy() const { + return healthy_.load(); +} + +void PeerClient::ensure_client() { + if (!client_) { + client_ = std::make_unique(url_); + client_->set_connection_timeout(timeout_ms_ / 1000, (timeout_ms_ % 1000) * 1000); + client_->set_read_timeout(timeout_ms_ / 1000, (timeout_ms_ % 1000) * 1000); + // Note: cpp-httplib Client does not expose set_payload_max_length (server-only). + // Response size is enforced post-download in forward_request() and + // forward_and_get_json() via MAX_PEER_RESPONSE_SIZE body length checks. + // The read timeout provides a secondary defense against slow-drip attacks. + } +} + +void PeerClient::check_health() { + std::lock_guard lock(client_mutex_); + ensure_client(); + auto result = client_->Get(std::string(API_PREFIX) + "/health"); + healthy_.store(result && result->status == 200); +} + +tl::expected PeerClient::fetch_entities() { + // Use a dedicated client for this long-running operation (4 sequential HTTP + // requests, up to 8s with 2s timeout) to avoid blocking health checks and + // forwarding on the shared client_mutex_. + httplib::Client cli(url_); + cli.set_connection_timeout(timeout_ms_ / 1000, (timeout_ms_ % 1000) * 1000); + cli.set_read_timeout(timeout_ms_ / 1000, (timeout_ms_ % 1000) * 1000); + + PeerEntities entities; + const std::string peer_source = "peer:" + name_; + + // Fetch areas + { + auto result = cli.Get(std::string(API_PREFIX) + "/areas"); + if (!result) { + return tl::unexpected("Failed to connect to peer '" + name_ + "' at " + url_); + } + if (result->status != 200) { + return tl::unexpected("Peer '" + name_ + "' returned status " + std::to_string(result->status) + + " for /areas"); + } + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + return tl::unexpected("Response from peer '" + name_ + "' for /areas exceeds size limit"); + } + auto response_json = nlohmann::json::parse(result->body, nullptr, false); + if (response_json.is_discarded()) { + return tl::unexpected("Invalid JSON from peer '" + name_ + "' for /areas"); + } + entities.areas = parse_collection(response_json, parse_area); + for (auto & area : entities.areas) { + area.source = peer_source; + } + + // Fetch subareas for each top-level area (list endpoint filters them out) + size_t top_level_count = entities.areas.size(); + for (size_t i = 0; i < top_level_count; ++i) { + auto sub_result = cli.Get(std::string(API_PREFIX) + "/areas/" + entities.areas[i].id + "/subareas"); + if (sub_result && sub_result->status == 200 && sub_result->body.size() <= MAX_PEER_RESPONSE_SIZE) { + auto sub_json = nlohmann::json::parse(sub_result->body, nullptr, false); + if (!sub_json.is_discarded()) { + auto subareas = parse_collection(sub_json, parse_area); + for (auto & sub : subareas) { + sub.source = peer_source; + entities.areas.push_back(std::move(sub)); + } + } + } + } + } + + // Fetch components (list then detail per entity for full relationship data) + { + auto result = cli.Get(std::string(API_PREFIX) + "/components"); + if (!result) { + return tl::unexpected("Failed to connect to peer '" + name_ + "' at " + url_); + } + if (result->status != 200) { + return tl::unexpected("Peer '" + name_ + "' returned status " + std::to_string(result->status) + + " for /components"); + } + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + return tl::unexpected("Response from peer '" + name_ + "' for /components exceeds size limit"); + } + auto response_json = nlohmann::json::parse(result->body, nullptr, false); + if (response_json.is_discarded()) { + return tl::unexpected("Invalid JSON from peer '" + name_ + "' for /components"); + } + // Parse IDs from list, then fetch detail per entity for relationships + auto comp_list = parse_collection(response_json, parse_component); + for (auto & comp : comp_list) { + auto detail = cli.Get(std::string(API_PREFIX) + "/components/" + comp.id); + if (detail && detail->status == 200) { + auto detail_json = nlohmann::json::parse(detail->body, nullptr, false); + if (!detail_json.is_discarded()) { + comp = parse_component(detail_json); + } + } + comp.source = peer_source; + } + // Fetch subcomponents for each top-level component (list endpoint filters them out) + size_t top_comp_count = comp_list.size(); + for (size_t i = 0; i < top_comp_count; ++i) { + auto sub_result = cli.Get(std::string(API_PREFIX) + "/components/" + comp_list[i].id + "/subcomponents"); + if (sub_result && sub_result->status == 200 && sub_result->body.size() <= MAX_PEER_RESPONSE_SIZE) { + auto sub_json = nlohmann::json::parse(sub_result->body, nullptr, false); + if (!sub_json.is_discarded()) { + auto subcomps = parse_collection(sub_json, parse_component); + for (auto & sub : subcomps) { + // Fetch detail for each subcomponent to get full relationships + auto detail = cli.Get(std::string(API_PREFIX) + "/components/" + sub.id); + if (detail && detail->status == 200) { + auto detail_json = nlohmann::json::parse(detail->body, nullptr, false); + if (!detail_json.is_discarded()) { + sub = parse_component(detail_json); + } + } + sub.source = peer_source; + comp_list.push_back(std::move(sub)); + } + } + } + } + + entities.components = std::move(comp_list); + } + + // Fetch apps + { + auto result = cli.Get(std::string(API_PREFIX) + "/apps"); + if (!result) { + return tl::unexpected("Failed to connect to peer '" + name_ + "' at " + url_); + } + if (result->status != 200) { + return tl::unexpected("Peer '" + name_ + "' returned status " + std::to_string(result->status) + + " for /apps"); + } + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + return tl::unexpected("Response from peer '" + name_ + "' for /apps exceeds size limit"); + } + auto response_json = nlohmann::json::parse(result->body, nullptr, false); + if (response_json.is_discarded()) { + return tl::unexpected("Invalid JSON from peer '" + name_ + "' for /apps"); + } + entities.apps = parse_collection(response_json, parse_app); + for (auto & app : entities.apps) { + app.source = peer_source; + } + // Filter ROS 2 internal nodes (underscore prefix convention) at source. + // These are noise nodes like _param_client_node that should never appear + // as SOVD entities. + entities.apps.erase(std::remove_if(entities.apps.begin(), entities.apps.end(), + [](const App & app) { + return !app.id.empty() && app.id[0] == '_'; + }), + entities.apps.end()); + } + + // Fetch functions (list then detail per entity for hosts data) + { + auto result = cli.Get(std::string(API_PREFIX) + "/functions"); + if (!result) { + return tl::unexpected("Failed to connect to peer '" + name_ + "' at " + url_); + } + if (result->status != 200) { + return tl::unexpected("Peer '" + name_ + "' returned status " + std::to_string(result->status) + + " for /functions"); + } + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + return tl::unexpected("Response from peer '" + name_ + "' for /functions exceeds size limit"); + } + auto response_json = nlohmann::json::parse(result->body, nullptr, false); + if (response_json.is_discarded()) { + return tl::unexpected("Invalid JSON from peer '" + name_ + "' for /functions"); + } + // Parse IDs from list, then fetch detail per entity for hosts + auto func_list = parse_collection(response_json, parse_function); + for (auto & func : func_list) { + auto detail = cli.Get(std::string(API_PREFIX) + "/functions/" + func.id); + if (detail && detail->status == 200) { + auto detail_json = nlohmann::json::parse(detail->body, nullptr, false); + if (!detail_json.is_discarded()) { + func = parse_function(detail_json); + } + } + func.source = peer_source; + } + entities.functions = std::move(func_list); + } + + return entities; +} + +void PeerClient::forward_request(const httplib::Request & req, httplib::Response & res) { + std::lock_guard lock(client_mutex_); + ensure_client(); + + httplib::Headers headers; + // Forward Authorization header only when explicitly enabled (forward_auth). + // Default is off to prevent token leakage to untrusted/mDNS-discovered peers. + if (forward_auth_ && req.has_header("Authorization")) { + headers.emplace("Authorization", req.get_header_value("Authorization")); + } + + httplib::Result result{nullptr, httplib::Error::Unknown}; + const std::string & path = req.path; + const std::string content_type = req.get_header_value("Content-Type"); + + if (req.method == "GET") { + result = client_->Get(path, headers); + } else if (req.method == "POST") { + result = client_->Post(path, headers, req.body, content_type); + } else if (req.method == "PUT") { + result = client_->Put(path, headers, req.body, content_type); + } else if (req.method == "DELETE") { + result = client_->Delete(path, headers); + } else if (req.method == "PATCH") { + result = client_->Patch(path, headers, req.body, content_type); + } + + if (!result) { + res.status = 502; + auto error_body = make_error_body(ERR_VENDOR_ERROR, "Peer '" + name_ + "' at " + url_ + " is unavailable", + ERR_X_MEDKIT_PEER_UNAVAILABLE); + res.set_content(error_body.dump(), "application/json"); + return; + } + + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + res.status = 502; + auto error_body = make_error_body(ERR_VENDOR_ERROR, "Response from peer '" + name_ + "' exceeds size limit", + ERR_X_MEDKIT_PEER_UNAVAILABLE); + res.set_content(error_body.dump(), "application/json"); + return; + } + + // Copy response from peer - only forward safe headers + static const std::set allowed_headers = {"content-type", "etag", "cache-control", "last-modified"}; + + res.status = result->status; + res.body = result->body; + for (const auto & header : result->headers) { + std::string lower_name = header.first; + std::transform(lower_name.begin(), lower_name.end(), lower_name.begin(), ::tolower); + if (allowed_headers.count(lower_name) > 0 || lower_name.find("x-medkit") == 0) { + res.set_header(header.first, header.second); + } + } +} + +tl::expected PeerClient::forward_and_get_json(const std::string & method, + const std::string & path, + const std::string & auth_header) { + std::lock_guard lock(client_mutex_); + ensure_client(); + + httplib::Headers headers; + // Only forward auth header when forward_auth is enabled + if (forward_auth_ && !auth_header.empty()) { + headers.emplace("Authorization", auth_header); + } + + httplib::Result result{nullptr, httplib::Error::Unknown}; + + if (method == "GET") { + result = client_->Get(path, headers); + } else if (method == "POST") { + result = client_->Post(path, headers, "", "application/json"); + } else if (method == "PUT") { + result = client_->Put(path, headers, "", "application/json"); + } else if (method == "DELETE") { + result = client_->Delete(path, headers); + } + + if (!result) { + return tl::unexpected("Failed to connect to peer '" + name_ + "' at " + url_); + } + + if (result->status < 200 || result->status >= 300) { + return tl::unexpected("Peer '" + name_ + "' returned status " + std::to_string(result->status) + + " for " + method + " " + path); + } + + if (result->body.size() > MAX_PEER_RESPONSE_SIZE) { + return tl::unexpected("Response from peer '" + name_ + "' exceeds size limit for " + method + " " + + path); + } + + auto parsed = nlohmann::json::parse(result->body, nullptr, false); + if (parsed.is_discarded()) { + return tl::unexpected("Invalid JSON response from peer '" + name_ + "' for " + method + " " + path); + } + + return parsed; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/aggregation/sse_stream_proxy.cpp b/src/ros2_medkit_gateway/src/aggregation/sse_stream_proxy.cpp new file mode 100644 index 000000000..1c6c4dc77 --- /dev/null +++ b/src/ros2_medkit_gateway/src/aggregation/sse_stream_proxy.cpp @@ -0,0 +1,207 @@ +// 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/aggregation/stream_proxy.hpp" + +#include +#include +#include +#include + +namespace ros2_medkit_gateway { + +SSEStreamProxy::SSEStreamProxy(const std::string & peer_url, const std::string & path, const std::string & peer_name) + : peer_url_(peer_url), path_(path), peer_name_(peer_name) { +} + +SSEStreamProxy::~SSEStreamProxy() { + close(); +} + +void SSEStreamProxy::open() { + if (connected_.load()) { + return; + } + should_stop_.store(false); + reader_thread_ = std::thread(&SSEStreamProxy::reader_loop, this); +} + +void SSEStreamProxy::close() { + should_stop_.store(true); + connected_.store(false); + if (reader_thread_.joinable()) { + reader_thread_.join(); + } +} + +bool SSEStreamProxy::is_connected() const { + return connected_.load(); +} + +void SSEStreamProxy::on_event(std::function cb) { + callback_ = std::move(cb); +} + +void SSEStreamProxy::reader_loop() { + httplib::Client client(peer_url_); + // Set reasonable timeouts - SSE connections are long-lived. + // Use a long read timeout instead of 0 (which causes immediate timeout in + // cpp-httplib when SO_RCVTIMEO is set to zero). 24 hours allows SSE streams + // to stay open indefinitely while still having a finite timeout for cleanup. + client.set_read_timeout(86400, 0); + client.set_connection_timeout(5, 0); + + connected_.store(true); + + // Use chunked content receiver to process SSE data as it arrives + std::string buffer; + auto result = client.Get( + path_, + [this](const httplib::Response & response) { + // Header callback - check that we got the right content type + return response.status == 200 && !should_stop_.load(); + }, + [this, &buffer](const char * data, size_t data_length) { + if (should_stop_.load()) { + return false; // Stop receiving + } + + constexpr size_t kMaxSSEBufferSize = 1 * 1024 * 1024; // 1MB + + buffer.append(data, data_length); + if (buffer.size() > kMaxSSEBufferSize) { + return false; // Disconnect - peer sending malformed stream + } + + // Process complete events (delimited by double newline) + size_t pos = 0; + while (true) { + auto boundary = buffer.find("\n\n", pos); + if (boundary == std::string::npos) { + break; + } + + std::string event_block = buffer.substr(pos, boundary - pos + 1); + pos = boundary + 2; + + auto events = parse_sse_data(event_block, peer_name_); + if (callback_) { + for (const auto & event : events) { + callback_(event); + } + } + } + + // Keep unprocessed data in buffer + if (pos > 0) { + buffer.erase(0, pos); + } + + return true; // Continue receiving + }); + + connected_.store(false); +} + +std::vector SSEStreamProxy::parse_sse_data(const std::string & raw, const std::string & peer) { + std::vector events; + + if (raw.empty()) { + return events; + } + + // Current event being built + std::string current_event_type; + std::string current_data; + std::string current_id; + bool has_data = false; + + std::istringstream stream(raw); + std::string line; + + while (std::getline(stream, line)) { + // Remove trailing \r if present (handles \r\n line endings) + if (!line.empty() && line.back() == '\r') { + line.pop_back(); + } + + // Blank line = event boundary + if (line.empty()) { + if (has_data) { + StreamEvent event; + event.event_type = current_event_type.empty() ? "message" : current_event_type; + event.data = current_data; + event.id = current_id; + event.peer_name = peer; + events.push_back(std::move(event)); + } + // Reset for next event + current_event_type.clear(); + current_data.clear(); + current_id.clear(); + has_data = false; + continue; + } + + // Skip comment lines (starting with ':') + if (line[0] == ':') { + continue; + } + + // Parse field: value + auto colon_pos = line.find(':'); + if (colon_pos == std::string::npos) { + // Field with no value - ignored per SSE spec + continue; + } + + std::string field = line.substr(0, colon_pos); + std::string value; + if (colon_pos + 1 < line.size()) { + // Skip optional single space after colon + size_t value_start = colon_pos + 1; + if (value_start < line.size() && line[value_start] == ' ') { + value_start++; + } + value = line.substr(value_start); + } + + if (field == "event") { + current_event_type = value; + } else if (field == "data") { + if (has_data) { + current_data += "\n"; + } + current_data += value; + has_data = true; + } else if (field == "id") { + current_id = value; + } + // "retry" and unknown fields are ignored + } + + // Handle trailing event without final blank line + if (has_data) { + StreamEvent event; + event.event_type = current_event_type.empty() ? "message" : current_event_type; + event.data = current_data; + event.id = current_id; + event.peer_name = peer; + events.push_back(std::move(event)); + } + + return events; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp b/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp index 269712fa8..5d9e6118c 100644 --- a/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp +++ b/src/ros2_medkit_gateway/src/discovery/discovery_enums.cpp @@ -37,41 +37,4 @@ std::string discovery_mode_to_string(DiscoveryMode mode) { } } -ComponentGroupingStrategy parse_grouping_strategy(const std::string & str) { - if (str == "namespace") { - return ComponentGroupingStrategy::NAMESPACE; - } - return ComponentGroupingStrategy::NONE; -} - -std::string grouping_strategy_to_string(ComponentGroupingStrategy strategy) { - switch (strategy) { - case ComponentGroupingStrategy::NAMESPACE: - return "namespace"; - default: - return "none"; - } -} - -TopicOnlyPolicy parse_topic_only_policy(const std::string & str) { - if (str == "ignore") { - return TopicOnlyPolicy::IGNORE; - } - if (str == "create_area_only") { - return TopicOnlyPolicy::CREATE_AREA_ONLY; - } - return TopicOnlyPolicy::CREATE_COMPONENT; -} - -std::string topic_only_policy_to_string(TopicOnlyPolicy policy) { - switch (policy) { - case TopicOnlyPolicy::IGNORE: - return "ignore"; - case TopicOnlyPolicy::CREATE_AREA_ONLY: - return "create_area_only"; - default: - return "create_component"; - } -} - } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp index b127accf5..9015db98f 100644 --- a/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp +++ b/src/ros2_medkit_gateway/src/discovery/discovery_manager.cpp @@ -35,6 +35,16 @@ bool DiscoveryManager::initialize(const DiscoveryConfig & config) { RCLCPP_INFO(node_->get_logger(), "Initializing discovery with mode: %s", discovery_mode_to_string(config.mode).c_str()); + // Create HostInfoProvider when default component is enabled (runtime_only mode) + if (config.runtime.default_component_enabled) { + host_info_provider_ = std::make_unique(); + RCLCPP_INFO(node_->get_logger(), "Default component enabled: id='%s' (%s)", + host_info_provider_->get_default_component().id.c_str(), + host_info_provider_->get_default_component().description.c_str()); + } else { + host_info_provider_.reset(); + } + // Create manifest manager if needed if (config.mode == DiscoveryMode::MANIFEST_ONLY || config.mode == DiscoveryMode::HYBRID) { manifest_manager_ = std::make_unique(node_); @@ -96,12 +106,7 @@ void DiscoveryManager::apply_layer_policy_overrides(const std::string & layer_na void DiscoveryManager::create_strategy() { // Configure runtime strategy with runtime options discovery::RuntimeDiscoveryStrategy::RuntimeConfig runtime_config; - runtime_config.create_synthetic_areas = config_.runtime.create_synthetic_areas; - runtime_config.create_synthetic_components = config_.runtime.create_synthetic_components; - runtime_config.grouping = config_.runtime.grouping; - runtime_config.synthetic_component_name_pattern = config_.runtime.synthetic_component_name_pattern; - runtime_config.topic_only_policy = config_.runtime.topic_only_policy; - runtime_config.min_topics_for_component = config_.runtime.min_topics_for_component; + runtime_config.create_functions_from_namespaces = config_.runtime.create_functions_from_namespaces; runtime_strategy_->set_config(runtime_config); switch (config_.mode) { @@ -158,8 +163,8 @@ void DiscoveryManager::create_strategy() { default: active_strategy_ = runtime_strategy_.get(); - RCLCPP_INFO(node_->get_logger(), "Discovery mode: runtime_only (synthetic_components=%s)", - config_.runtime.create_synthetic_components ? "true" : "false"); + RCLCPP_INFO(node_->get_logger(), "Discovery mode: runtime_only (default_component=%s)", + host_info_provider_ ? "true" : "false"); break; } } @@ -175,6 +180,13 @@ std::vector DiscoveryManager::discover_components() { if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { return manifest_manager_->get_components(); } + + // In RUNTIME_ONLY mode with host info provider, return only the + // single host-derived Component instead of synthetic namespace components + if (config_.mode == DiscoveryMode::RUNTIME_ONLY && host_info_provider_) { + return {host_info_provider_->get_default_component()}; + } + return active_strategy_->discover_components(); } @@ -192,6 +204,18 @@ std::vector DiscoveryManager::discover_functions() { return active_strategy_->discover_functions(); } +std::vector DiscoveryManager::discover_functions(const std::vector & apps) { + if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { + return manifest_manager_->get_functions(); + } + // In RUNTIME_ONLY mode, delegate to the overload that accepts pre-discovered apps + if (config_.mode == DiscoveryMode::RUNTIME_ONLY && runtime_strategy_) { + return runtime_strategy_->discover_functions(apps); + } + // HYBRID mode uses cached pipeline results, so apps parameter is not needed + return active_strategy_->discover_functions(); +} + std::optional DiscoveryManager::get_area(const std::string & id) { // In MANIFEST_ONLY mode, use direct manifest lookup (O(1)) if (config_.mode == DiscoveryMode::MANIFEST_ONLY && manifest_manager_ && manifest_manager_->is_manifest_active()) { @@ -306,13 +330,14 @@ std::vector DiscoveryManager::get_hosts_for_function(const std::str if (manifest_manager_ && manifest_manager_->is_manifest_active()) { return manifest_manager_->get_hosts_for_function(function_id); } + // Check strategy-discovered functions (e.g., runtime namespace-based functions) + auto func = get_function(function_id); + if (func) { + return func->hosts; + } return {}; } -std::vector DiscoveryManager::discover_topic_components() { - return runtime_strategy_->discover_topic_components(); -} - std::vector DiscoveryManager::discover_services() { return runtime_strategy_->discover_services(); } @@ -390,4 +415,15 @@ std::optional DiscoveryManager::get_linking_result() c return std::nullopt; } +bool DiscoveryManager::has_host_info_provider() const { + return host_info_provider_ != nullptr; +} + +std::optional DiscoveryManager::get_default_component() const { + if (host_info_provider_) { + return host_info_provider_->get_default_component(); + } + return std::nullopt; +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/host_info_provider.cpp b/src/ros2_medkit_gateway/src/discovery/host_info_provider.cpp new file mode 100644 index 000000000..64dbe8285 --- /dev/null +++ b/src/ros2_medkit_gateway/src/discovery/host_info_provider.cpp @@ -0,0 +1,98 @@ +// 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/discovery/host_info_provider.hpp" + +#include +#include + +#include +#include +#include + +namespace ros2_medkit_gateway { + +HostInfoProvider::HostInfoProvider() { + read_host_info(); + build_component(); +} + +void HostInfoProvider::read_host_info() { + // Hostname via gethostname() + char buf[256]; // NOLINT(cppcoreguidelines-avoid-c-arrays) + if (gethostname(buf, sizeof(buf)) == 0) { + buf[sizeof(buf) - 1] = '\0'; + hostname_ = buf; + } else { + hostname_ = "unknown"; + } + + // OS via /etc/os-release PRETTY_NAME + os_ = "Unknown OS"; + std::ifstream os_release("/etc/os-release"); + if (os_release.is_open()) { + std::string line; + while (std::getline(os_release, line)) { + if (line.rfind("PRETTY_NAME=", 0) == 0) { + // Strip PRETTY_NAME= prefix and surrounding quotes + os_ = line.substr(12); + if (os_.size() >= 2 && os_.front() == '"' && os_.back() == '"') { + os_ = os_.substr(1, os_.size() - 2); + } + break; + } + } + } + + // Architecture via uname() + struct utsname uts {}; + if (uname(&uts) == 0) { + arch_ = uts.machine; + } else { + arch_ = "unknown"; + } +} + +std::string HostInfoProvider::sanitize_entity_id(const std::string & input) { + std::string result; + result.reserve(input.size()); + + for (char c : input) { + if (c == '.' || c == ' ') { + result += '_'; + } else if (std::isalnum(static_cast(c)) || c == '_' || c == '-') { + result += static_cast(std::tolower(static_cast(c))); + } + // Strip all other characters + } + + // Truncate to max 256 characters + constexpr size_t kMaxEntityIdLength = 256; + if (result.size() > kMaxEntityIdLength) { + result.resize(kMaxEntityIdLength); + } + + return result; +} + +void HostInfoProvider::build_component() { + component_.id = sanitize_entity_id(hostname_); + component_.name = hostname_; + component_.type = "Component"; + component_.source = "runtime"; + component_.description = os_ + " on " + arch_; + component_.host_metadata = json{{"hostname", hostname_}, {"os", os_}, {"arch", arch_}}; +} + +} // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/discovery/layers/runtime_layer.cpp b/src/ros2_medkit_gateway/src/discovery/layers/runtime_layer.cpp index ea394ab1f..b2666de3d 100644 --- a/src/ros2_medkit_gateway/src/discovery/layers/runtime_layer.cpp +++ b/src/ros2_medkit_gateway/src/discovery/layers/runtime_layer.cpp @@ -15,7 +15,6 @@ #include "ros2_medkit_gateway/discovery/layers/runtime_layer.hpp" #include -#include #include namespace ros2_medkit_gateway { @@ -44,18 +43,6 @@ bool is_namespace_allowed(const std::string & ns, const GapFillConfig & config) return true; } -// Filter entities with namespace_path by gap-fill config, returns count of removed entities -template -size_t filter_by_namespace(std::vector & entities, const GapFillConfig & config) { - size_t before = entities.size(); - entities.erase(std::remove_if(entities.begin(), entities.end(), - [&config](const Entity & e) { - return !is_namespace_allowed(e.namespace_path, config); - }), - entities.end()); - return before - entities.size(); -} - // Extract namespace from a fully-qualified node name (e.g. "/ns/sub/node" -> "/ns/sub") std::string namespace_from_fqn(const std::string & fqn) { auto pos = fqn.rfind('/'); @@ -97,35 +84,23 @@ LayerOutput RuntimeLayer::discover() { return output; } - if (gap_fill_config_.allow_heuristic_areas) { - output.areas = runtime_strategy_->discover_areas(); - last_filtered_count_ += filter_by_namespace(output.areas, gap_fill_config_); - } + // Areas and Components are never created by runtime discovery. + // Areas come from manifest only. Components come from HostInfoProvider or manifest. - // Discover apps once - used by both components (synthetic grouping) and apps output. - // Always save unfiltered apps for post-merge linking. The linker needs all runtime - // apps to bind manifest apps to live nodes, regardless of gap-fill settings. + // Discover apps once. Always save unfiltered apps for post-merge linking. + // The linker needs all runtime apps to bind manifest apps to live nodes, + // regardless of gap-fill settings. auto apps = runtime_strategy_->discover_apps(); linking_apps_ = apps; - if (gap_fill_config_.allow_heuristic_components) { - output.components = runtime_strategy_->discover_components(apps); - - // Topic components are discovered separately and must be included - auto topic_components = runtime_strategy_->discover_topic_components(); - output.components.insert(output.components.end(), std::make_move_iterator(topic_components.begin()), - std::make_move_iterator(topic_components.end())); - - last_filtered_count_ += filter_by_namespace(output.components, gap_fill_config_); - } - if (gap_fill_config_.allow_heuristic_apps) { output.apps = std::move(apps); last_filtered_count_ += filter_apps_by_namespace(output.apps, gap_fill_config_); } if (gap_fill_config_.allow_heuristic_functions) { - output.functions = runtime_strategy_->discover_functions(); + // Use the pre-discovered apps to avoid redundant ROS 2 graph introspection + output.functions = runtime_strategy_->discover_functions(linking_apps_); } return output; diff --git a/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp b/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp index b97f7c9d6..3357c4fd5 100644 --- a/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp +++ b/src/ros2_medkit_gateway/src/discovery/manifest/runtime_linker.cpp @@ -192,7 +192,7 @@ LinkingResult RuntimeLinker::link(const std::vector & manifest_apps, const } auto it = std::remove_if(result.linked_apps.begin(), result.linked_apps.end(), [&](const App & app) { - if (!is_runtime_source(app.source)) { + if (is_protected_source(app.source)) { return false; } if (!app.bound_fqn.has_value()) { diff --git a/src/ros2_medkit_gateway/src/discovery/merge_pipeline.cpp b/src/ros2_medkit_gateway/src/discovery/merge_pipeline.cpp index 5b6ac6128..cabe5dc59 100644 --- a/src/ros2_medkit_gateway/src/discovery/merge_pipeline.cpp +++ b/src/ros2_medkit_gateway/src/discovery/merge_pipeline.cpp @@ -527,48 +527,80 @@ MergeResult MergePipeline::execute() { } } - // Remove heuristic apps that were merged into manifest apps (same ID after merge). - // These are runtime duplicates of linked manifest entities. Gap-fill apps (new - // heuristic apps in any namespace) survive - they fill manifest gaps intentionally. + // Build set of orphan FQNs for policy-based filtering + std::set orphan_fqns(linking.orphan_nodes.begin(), linking.orphan_nodes.end()); + + // Remove heuristic apps based on unmanifested_nodes policy and linking results. std::set manifest_app_ids; for (const auto & app : result.apps) { if (app.source == "manifest") { manifest_app_ids.insert(app.id); } } - // In practice this is a defensive no-op: real suppression of same-ID apps happens during - // merge_entities (manifest wins), and different-ID runtime apps are removed by the linker's - // FQN-based dedup. This layer exists as future-proofing in case the linker is extended to - // produce non-manifest IDs. node_to_app values are currently always manifest app IDs. std::set linked_app_ids(manifest_app_ids); for (const auto & [fqn, app_id] : linking_result_.node_to_app) { linked_app_ids.insert(app_id); } + + bool hide_orphans = manifest_config_.unmanifested_nodes == ManifestConfig::UnmanifestedNodePolicy::IGNORE; + auto app_it = std::remove_if(result.apps.begin(), result.apps.end(), [&](const App & app) { - if (!is_runtime_source(app.source)) { + // Whitelist: manifest and plugin sources are always preserved + if (is_protected_source(app.source)) { return false; } - // Keep gap-fill apps (not linked to any manifest entity) - // Suppress only if this app's ID matches a linked manifest app - return linked_app_ids.count(app.id) > 0; + // Suppress non-protected apps whose ID matches a linked manifest app (dedup) + if (linked_app_ids.count(app.id) > 0) { + return true; + } + // When unmanifested_nodes=ignore, suppress all non-linked, non-protected apps. + // This covers heuristic, topic, synthetic, node, runtime, and any other source. + if (hide_orphans) { + return true; + } + return false; }); result.apps.erase(app_it, result.apps.end()); - // Remove runtime components whose namespace is covered + // Also collect orphan namespaces for component/area suppression + std::set orphan_namespaces; + if (hide_orphans) { + for (const auto & fqn : orphan_fqns) { + auto last_slash = fqn.rfind('/'); + if (last_slash != std::string::npos && last_slash > 0) { + orphan_namespaces.insert(fqn.substr(0, last_slash)); + } + } + } + + // Remove non-protected components whose namespace is covered by manifest or orphan suppression auto comp_it = std::remove_if(result.components.begin(), result.components.end(), [&](const Component & comp) { - if (!is_runtime_source(comp.source)) { + if (is_protected_source(comp.source)) { return false; } - return manifest_comp_ns.count(comp.namespace_path) > 0 || linked_namespaces.count(comp.namespace_path) > 0; + if (manifest_comp_ns.count(comp.namespace_path) > 0 || linked_namespaces.count(comp.namespace_path) > 0) { + return true; + } + // When hiding orphans, also suppress components from orphan-only namespaces + if (hide_orphans && orphan_namespaces.count(comp.namespace_path) > 0) { + return true; + } + return false; }); result.components.erase(comp_it, result.components.end()); - // Remove runtime areas whose namespace is covered + // Remove non-protected areas whose namespace is covered auto area_it = std::remove_if(result.areas.begin(), result.areas.end(), [&](const Area & area) { - if (!is_runtime_source(area.source)) { + if (is_protected_source(area.source)) { return false; } - return manifest_area_ns.count(area.namespace_path) > 0 || linked_namespaces.count(area.namespace_path) > 0; + if (manifest_area_ns.count(area.namespace_path) > 0 || linked_namespaces.count(area.namespace_path) > 0) { + return true; + } + if (hide_orphans && orphan_namespaces.count(area.namespace_path) > 0) { + return true; + } + return false; }); result.areas.erase(area_it, result.areas.end()); diff --git a/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp index 124bc2b6f..93febee3b 100644 --- a/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp +++ b/src/ros2_medkit_gateway/src/discovery/runtime_discovery.cpp @@ -14,9 +14,6 @@ #include "ros2_medkit_gateway/discovery/runtime_discovery.hpp" -#include "ros2_medkit_gateway/discovery/discovery_manager.hpp" - -#include #include #include @@ -59,94 +56,18 @@ RuntimeDiscoveryStrategy::RuntimeDiscoveryStrategy(rclcpp::Node * node) : node_( void RuntimeDiscoveryStrategy::set_config(const RuntimeConfig & config) { config_ = config; - RCLCPP_DEBUG( - node_->get_logger(), "Runtime discovery config: synthetic_areas=%s, synthetic_components=%s, grouping=%s", - config_.create_synthetic_areas ? "true" : "false", config_.create_synthetic_components ? "true" : "false", - grouping_strategy_to_string(config_.grouping).c_str()); + RCLCPP_DEBUG(node_->get_logger(), "Runtime discovery config: functions_from_namespaces=%s", + config_.create_functions_from_namespaces ? "true" : "false"); } std::vector RuntimeDiscoveryStrategy::discover_areas() { - if (!config_.create_synthetic_areas) { - return {}; - } - - // Extract unique areas from namespaces - std::set area_set; - - // Get node graph interface - auto node_graph = node_->get_node_graph_interface(); - - // Iterate through all nodes to find namespaces - auto names_and_namespaces = node_graph->get_node_names_and_namespaces(); - - for (const auto & name_and_ns : names_and_namespaces) { - std::string ns = name_and_ns.second; - std::string area = extract_area_from_namespace(ns); - area_set.insert(area); - } - - // Also include areas from topic namespaces (for topic-based discovery) - if (topic_sampler_) { - auto topic_namespaces = topic_sampler_->discover_topic_namespaces(); - for (const auto & ns : topic_namespaces) { - area_set.insert(ns); - } - } - - // Filter out topic "namespaces" that are actually root-namespace node names. - // Root-namespace nodes publish topics with their node name as prefix - // (e.g., /fault_manager publishes /fault_manager/events), which looks like - // a topic in namespace "fault_manager". These nodes belong to area "root". - // Only erase if the name was NOT also derived from actual node namespaces - // (protects legitimate areas when a root node shares a name with a namespace). - std::set node_derived_areas; - for (const auto & [n, ns] : names_and_namespaces) { - node_derived_areas.insert(extract_area_from_namespace(ns)); - } - for (const auto & [name, ns] : names_and_namespaces) { - if (ns == "/" && node_derived_areas.find(name) == node_derived_areas.end()) { - area_set.erase(name); - } - } - - // Convert set to vector of Area structs - std::vector areas; - for (const auto & area_name : area_set) { - Area area; - area.id = area_name; - area.namespace_path = (area_name == "root") ? "/" : "/" + area_name; - area.source = "heuristic"; - areas.push_back(area); - } - - return areas; + // Areas come from manifest only - runtime discovery never creates Areas. + return {}; } std::vector RuntimeDiscoveryStrategy::discover_components() { - return discover_components(discover_apps()); -} - -std::vector RuntimeDiscoveryStrategy::discover_components(const std::vector & apps) { - if (!config_.create_synthetic_components) { - // Legacy mode: each App becomes its own Component (1:1 mapping) - std::vector components; - components.reserve(apps.size()); - for (const auto & app : apps) { - Component comp; - comp.id = app.id; - comp.source = "heuristic"; - if (app.bound_fqn.has_value()) { - comp.fqn = app.bound_fqn.value(); - auto slash_pos = comp.fqn.rfind('/'); - comp.namespace_path = (slash_pos == std::string::npos || slash_pos == 0) ? "/" : comp.fqn.substr(0, slash_pos); - comp.area = extract_area_from_namespace(comp.namespace_path); - } - components.push_back(std::move(comp)); - } - return components; - } - - return discover_synthetic_components(apps); + // Components come from HostInfoProvider or manifest - runtime discovery never creates Components. + return {}; } std::vector RuntimeDiscoveryStrategy::discover_apps() { @@ -197,9 +118,6 @@ std::vector RuntimeDiscoveryStrategy::discover_apps() { app.is_online = true; app.bound_fqn = fqn; - std::string area = extract_area_from_namespace(ns); - app.component_id = derive_component_id(name, area); - // Introspect services and actions for this node try { auto node_services = node_->get_service_names_and_types_by_node(name, ns); @@ -258,9 +176,51 @@ std::vector RuntimeDiscoveryStrategy::discover_apps() { } std::vector RuntimeDiscoveryStrategy::discover_functions() { - // Functions are not supported in runtime-only mode - // They require manifest definitions - return {}; + if (!config_.create_functions_from_namespaces) { + return {}; + } + // Discover apps fresh when called without pre-discovered apps + return discover_functions(discover_apps()); +} + +std::vector RuntimeDiscoveryStrategy::discover_functions(const std::vector & apps) { + if (!config_.create_functions_from_namespaces) { + return {}; + } + + // Group apps by namespace (first segment), similar to discover_areas() logic + std::map> ns_to_app_ids; + for (const auto & app : apps) { + std::string ns = "/"; + if (app.bound_fqn.has_value()) { + const auto & fqn = app.bound_fqn.value(); + auto pos = fqn.rfind('/'); + ns = (pos == std::string::npos || pos == 0) ? "/" : fqn.substr(0, pos); + } + std::string area = extract_area_from_namespace(ns); + ns_to_app_ids[area].push_back(app.id); + } + + // Create Function entities from namespace groups + std::vector functions; + for (const auto & [ns_name, app_ids] : ns_to_app_ids) { + if (app_ids.empty()) { + continue; + } + + Function func; + func.id = ns_name; + func.name = ns_name; + func.source = "runtime"; + func.hosts = app_ids; + + RCLCPP_DEBUG(node_->get_logger(), "Created runtime function '%s' with %zu host apps", ns_name.c_str(), + app_ids.size()); + functions.push_back(std::move(func)); + } + + RCLCPP_DEBUG(node_->get_logger(), "Discovered %zu functions from namespace grouping", functions.size()); + return functions; } std::vector RuntimeDiscoveryStrategy::discover_services() { @@ -470,98 +430,6 @@ std::string RuntimeDiscoveryStrategy::extract_name_from_path(const std::string & return path; } -std::set RuntimeDiscoveryStrategy::get_node_namespaces() { - std::set namespaces; - - auto node_graph = node_->get_node_graph_interface(); - auto names_and_namespaces = node_graph->get_node_names_and_namespaces(); - - for (const auto & name_and_ns : names_and_namespaces) { - std::string node_name = name_and_ns.first; - std::string ns = name_and_ns.second; - std::string area = extract_area_from_namespace(ns); - if (area != "root") { - namespaces.insert(area); - } else { - // For root namespace nodes, add the node name to prevent - // topic-based discovery from creating duplicate components - // e.g., node /fault_manager publishes /fault_manager/events - namespaces.insert(node_name); - } - } - - return namespaces; -} - -std::vector RuntimeDiscoveryStrategy::discover_topic_components() { - std::vector components; - - // Check policy - if IGNORE, don't create any topic-based entities - if (config_.topic_only_policy == TopicOnlyPolicy::IGNORE) { - RCLCPP_DEBUG(node_->get_logger(), "Topic-only policy is IGNORE, skipping topic-based discovery"); - return components; - } - - // If CREATE_AREA_ONLY, areas are created in discover() but no components here - if (config_.topic_only_policy == TopicOnlyPolicy::CREATE_AREA_ONLY) { - RCLCPP_DEBUG(node_->get_logger(), "Topic-only policy is CREATE_AREA_ONLY, skipping component creation"); - return components; - } - - if (!topic_sampler_) { - RCLCPP_DEBUG(node_->get_logger(), "Topic sampler not set, skipping topic-based discovery"); - return components; - } - - // Single graph query - get all namespaces and their topics at once (avoids N+1 queries) - auto discovery_result = topic_sampler_->discover_topics_by_namespace(); - - // Get namespaces that already have nodes (to avoid duplicates) - auto node_namespaces = get_node_namespaces(); - - RCLCPP_DEBUG(node_->get_logger(), "Topic-based discovery: %zu topic namespaces, %zu node namespaces", - discovery_result.namespaces.size(), node_namespaces.size()); - - for (const auto & ns : discovery_result.namespaces) { - // Skip if there's already a node with this namespace - if (node_namespaces.count(ns) > 0) { - RCLCPP_DEBUG(node_->get_logger(), "Skipping namespace '%s' - already has nodes", ns.c_str()); - continue; - } - - // Get topics from cached result (no additional graph query) - std::string ns_prefix = "/" + ns; - auto it = discovery_result.topics_by_ns.find(ns_prefix); - if (it == discovery_result.topics_by_ns.end()) { - continue; - } - - // Check minimum topics threshold - size_t topic_count = it->second.publishes.size() + it->second.subscribes.size(); - if (static_cast(topic_count) < config_.min_topics_for_component) { - RCLCPP_DEBUG(node_->get_logger(), "Skipping namespace '%s' - topic count %zu < min %d", ns.c_str(), topic_count, - config_.min_topics_for_component); - continue; - } - - Component comp; - comp.id = ns; - comp.namespace_path = "/" + ns; - comp.fqn = "/" + ns; - comp.area = ns; - comp.source = "topic"; - comp.topics = it->second; - - RCLCPP_DEBUG(node_->get_logger(), "Created topic-based component '%s' with %zu topics", ns.c_str(), - comp.topics.publishes.size()); - - components.push_back(comp); - } - - RCLCPP_INFO(node_->get_logger(), "Discovered %zu topic-based components", components.size()); - return components; -} - bool RuntimeDiscoveryStrategy::path_belongs_to_namespace(const std::string & path, const std::string & ns) const { if (ns.empty() || ns == "/") { // Root namespace - check if path has only one segment after leading slash @@ -591,81 +459,5 @@ bool RuntimeDiscoveryStrategy::path_belongs_to_namespace(const std::string & pat return remainder.find('/') == std::string::npos; } -std::vector RuntimeDiscoveryStrategy::discover_synthetic_components(const std::vector & apps) { - // Group runtime apps by their component_id (already derived during discover_apps) - std::map> groups; - - for (const auto & app : apps) { - groups[app.component_id].push_back(&app); - } - - // Create synthetic components from groups - std::vector result; - for (const auto & [group_id, group_apps] : groups) { - Component comp; - comp.id = group_id; - comp.source = "synthetic"; - comp.type = "ComponentGroup"; - - // Use first app's FQN to derive namespace and area - if (!group_apps.empty() && group_apps[0]->bound_fqn.has_value()) { - const auto & fqn = group_apps[0]->bound_fqn.value(); - auto pos = fqn.rfind('/'); - comp.namespace_path = (pos == std::string::npos || pos == 0) ? "/" : fqn.substr(0, pos); - comp.area = extract_area_from_namespace(comp.namespace_path); - comp.fqn = "/" + group_id; - } - - // Topics/services stay with Apps - synthetic components are just groupings - - RCLCPP_DEBUG(node_->get_logger(), "Created synthetic component '%s' with %zu apps", group_id.c_str(), - group_apps.size()); - result.push_back(comp); - } - - RCLCPP_DEBUG(node_->get_logger(), "Discovered %zu synthetic components from %zu nodes", result.size(), apps.size()); - return result; -} - -std::string RuntimeDiscoveryStrategy::derive_component_id(const std::string & node_id, const std::string & area) { - switch (config_.grouping) { - case ComponentGroupingStrategy::NAMESPACE: - return apply_component_name_pattern(area); - case ComponentGroupingStrategy::NONE: - default: - return node_id; - } -} - -std::string RuntimeDiscoveryStrategy::apply_component_name_pattern(const std::string & area) { - // Validate inputs to prevent unexpected empty component IDs - if (area.empty()) { - RCLCPP_WARN(node_->get_logger(), "apply_component_name_pattern called with empty area, using 'unknown'"); - return apply_component_name_pattern("unknown"); - } - - if (config_.synthetic_component_name_pattern.empty()) { - RCLCPP_WARN(node_->get_logger(), "Empty synthetic_component_name_pattern, using area directly: %s", area.c_str()); - return area; - } - - std::string result = config_.synthetic_component_name_pattern; - - // Replace {area} placeholder - const std::string placeholder = "{area}"; - size_t pos = result.find(placeholder); - if (pos != std::string::npos) { - result.replace(pos, placeholder.length(), area); - } else { - // Pattern doesn't contain {area} - all synthetic components will have same ID - RCLCPP_WARN_ONCE(node_->get_logger(), - "synthetic_component_name_pattern '%s' doesn't contain {area} placeholder - " - "all synthetic components will have the same ID, which may cause collisions", - config_.synthetic_component_name_pattern.c_str()); - } - - return result; -} - } // namespace discovery } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/gateway_node.cpp b/src/ros2_medkit_gateway/src/gateway_node.cpp index d6e118263..02c09e293 100644 --- a/src/ros2_medkit_gateway/src/gateway_node.cpp +++ b/src/ros2_medkit_gateway/src/gateway_node.cpp @@ -21,6 +21,8 @@ #include #include +#include "ros2_medkit_gateway/aggregation/network_utils.hpp" + #include "ros2_medkit_gateway/http/handlers/sse_transport_provider.hpp" #include "ros2_medkit_gateway/sqlite_trigger_store.hpp" @@ -199,6 +201,26 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki declare_parameter("locking.defaults.apps.lock_required_scopes", std::vector{""}); declare_parameter("locking.defaults.apps.breakable", true); + // Aggregation parameters (peer gateway federation) + declare_parameter("aggregation.enabled", false); + declare_parameter("aggregation.timeout_ms", 2000); + declare_parameter("aggregation.announce", false); + declare_parameter("aggregation.discover", false); + declare_parameter("aggregation.mdns_service", std::string("_medkit._tcp.local")); + declare_parameter("aggregation.mdns_name", std::string("")); // defaults to hostname + // Security: forward Authorization header to peers (default: false to prevent token leakage) + declare_parameter("aggregation.forward_auth", false); + // Security: require TLS for all peer URLs (default: false) + declare_parameter("aggregation.require_tls", false); + // URL scheme for mDNS-discovered peer URLs (default: "http") + declare_parameter("aggregation.peer_scheme", std::string("http")); + // Maximum number of mDNS-discovered peers (prevents unbounded growth from rogue announcements) + declare_parameter("aggregation.max_discovered_peers", 50); + // Static peers: parallel arrays of URLs and names. + // Example: peer_urls=["http://localhost:8081"], peer_names=["subsystem_b"] + declare_parameter("aggregation.peer_urls", std::vector{""}); + declare_parameter("aggregation.peer_names", std::vector{""}); + // Bulk data storage parameters declare_parameter("bulk_data.storage_dir", "/tmp/ros2_medkit_bulk_data"); declare_parameter("bulk_data.max_upload_size", 104857600); // 100MB @@ -206,16 +228,11 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki // Runtime (heuristic) discovery options // These control how nodes are mapped to SOVD entities in runtime mode - declare_parameter("discovery.runtime.create_synthetic_areas", true); - declare_parameter("discovery.runtime.create_synthetic_components", true); - declare_parameter("discovery.runtime.grouping_strategy", "namespace"); - declare_parameter("discovery.runtime.synthetic_component_name_pattern", "{area}"); - declare_parameter("discovery.runtime.topic_only_policy", "create_component"); - declare_parameter("discovery.runtime.min_topics_for_component", 1); + declare_parameter("discovery.runtime.default_component.enabled", true); + declare_parameter("discovery.runtime.create_functions_from_namespaces", true); + declare_parameter("discovery.runtime.filter_internal_nodes", true); // Merge pipeline configuration (hybrid mode only) - declare_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_areas", true); - declare_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_components", true); declare_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_apps", true); declare_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_functions", false); declare_parameter("discovery.merge_pipeline.gap_fill.namespace_whitelist", std::vector{}); @@ -228,6 +245,35 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki } } + // Check for removed parameters and warn users with stale YAML configs. + // ROS 2 silently ignores undeclared parameters from YAML, so we must + // explicitly check the parameter overrides to detect stale configs. + { + const auto & overrides = this->get_node_options().parameter_overrides(); + // Use fully-qualified parameter names to match YAML override paths. + // E.g., YAML key "discovery.runtime.create_synthetic_areas" becomes + // override name "discovery.runtime.create_synthetic_areas". + static const std::vector> removed_params = { + {"discovery.runtime.create_synthetic_areas", "Areas now come from manifest only."}, + {"discovery.runtime.create_synthetic_components", "Components are now host-derived in runtime mode."}, + {"discovery.runtime.grouping_strategy", "Replaced by discovery.runtime.create_functions_from_namespaces."}, + {"discovery.runtime.synthetic_component_name_pattern", "Components are now host-derived in runtime mode."}, + {"discovery.runtime.topic_only_policy", "Topic-only discovery has been removed."}, + {"discovery.runtime.min_topics_for_component", "Topic-only discovery has been removed."}, + {"discovery.merge_pipeline.gap_fill.allow_heuristic_areas", "Areas now come from manifest only."}, + {"discovery.merge_pipeline.gap_fill.allow_heuristic_components", + "Components are now host-derived in runtime mode."}, + }; + for (const auto & [name, message] : removed_params) { + for (const auto & override : overrides) { + if (override.get_name() == name) { + RCLCPP_WARN(get_logger(), "Parameter '%s' has been removed. %s", name.c_str(), message.c_str()); + break; + } + } + } + } + // Get parameter values server_host_ = get_parameter("server.host").as_string(); server_port_ = static_cast(get_parameter("server.port").as_int()); @@ -435,34 +481,14 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki discovery_config.runtime_enabled = get_parameter("discovery.runtime.enabled").as_bool(); // Runtime discovery options - discovery_config.runtime.create_synthetic_areas = get_parameter("discovery.runtime.create_synthetic_areas").as_bool(); - discovery_config.runtime.create_synthetic_components = - get_parameter("discovery.runtime.create_synthetic_components").as_bool(); - - auto grouping_str = get_parameter("discovery.runtime.grouping_strategy").as_string(); - discovery_config.runtime.grouping = parse_grouping_strategy(grouping_str); - if (grouping_str != "none" && grouping_str != "namespace") { - RCLCPP_WARN(get_logger(), "Unknown grouping_strategy '%s', defaulting to 'none'", grouping_str.c_str()); - } - - discovery_config.runtime.synthetic_component_name_pattern = - get_parameter("discovery.runtime.synthetic_component_name_pattern").as_string(); - - auto topic_policy_str = get_parameter("discovery.runtime.topic_only_policy").as_string(); - discovery_config.runtime.topic_only_policy = parse_topic_only_policy(topic_policy_str); - if (topic_policy_str != "ignore" && topic_policy_str != "create_component" && - topic_policy_str != "create_area_only") { - RCLCPP_WARN(get_logger(), "Unknown topic_only_policy '%s', defaulting to 'create_component'", - topic_policy_str.c_str()); - } - discovery_config.runtime.min_topics_for_component = - static_cast(get_parameter("discovery.runtime.min_topics_for_component").as_int()); + discovery_config.runtime.default_component_enabled = + get_parameter("discovery.runtime.default_component.enabled").as_bool(); + discovery_config.runtime.create_functions_from_namespaces = + get_parameter("discovery.runtime.create_functions_from_namespaces").as_bool(); + discovery_config.runtime.filter_internal_nodes = get_parameter("discovery.runtime.filter_internal_nodes").as_bool(); + filter_internal_nodes_ = discovery_config.runtime.filter_internal_nodes; // Merge pipeline gap-fill configuration (hybrid mode) - discovery_config.merge_pipeline.gap_fill.allow_heuristic_areas = - get_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_areas").as_bool(); - discovery_config.merge_pipeline.gap_fill.allow_heuristic_components = - get_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_components").as_bool(); discovery_config.merge_pipeline.gap_fill.allow_heuristic_apps = get_parameter("discovery.merge_pipeline.gap_fill.allow_heuristic_apps").as_bool(); discovery_config.merge_pipeline.gap_fill.allow_heuristic_functions = @@ -841,6 +867,116 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki RCLCPP_INFO(get_logger(), "Trigger subsystem: disabled"); } + // --- Aggregation (peer gateway federation) --- + if (get_parameter("aggregation.enabled").as_bool()) { + AggregationConfig agg_config; + agg_config.enabled = true; + agg_config.timeout_ms = static_cast(get_parameter("aggregation.timeout_ms").as_int()); + agg_config.announce = get_parameter("aggregation.announce").as_bool(); + agg_config.discover = get_parameter("aggregation.discover").as_bool(); + agg_config.mdns_service = get_parameter("aggregation.mdns_service").as_string(); + agg_config.forward_auth = get_parameter("aggregation.forward_auth").as_bool(); + agg_config.require_tls = get_parameter("aggregation.require_tls").as_bool(); + agg_config.peer_scheme = get_parameter("aggregation.peer_scheme").as_string(); + agg_config.max_discovered_peers = static_cast(get_parameter("aggregation.max_discovered_peers").as_int()); + + // Parse static peers from parallel arrays + auto peer_urls = get_parameter("aggregation.peer_urls").as_string_array(); + auto peer_names = get_parameter("aggregation.peer_names").as_string_array(); + if (peer_urls.size() != peer_names.size()) { + RCLCPP_ERROR(get_logger(), + "Aggregation: peer_urls has %zu entries but peer_names has %zu. " + "These parallel arrays must have the same length. " + "All static peers will be ignored until the configuration is fixed.", + peer_urls.size(), peer_names.size()); + } else { + for (size_t i = 0; i < peer_urls.size(); ++i) { + if (!peer_urls[i].empty() && !peer_names[i].empty()) { + agg_config.peers.push_back({peer_urls[i], peer_names[i]}); + RCLCPP_INFO(get_logger(), "Aggregation: static peer '%s' at %s", peer_names[i].c_str(), peer_urls[i].c_str()); + } + } + } + + if (agg_config.peers.empty() && !agg_config.announce && !agg_config.discover) { + RCLCPP_WARN(get_logger(), + "Aggregation enabled but no static peers and mDNS disabled. " + "No peer communication will occur."); + } + + // Security warning: forwarding auth tokens over cleartext is dangerous + if (agg_config.forward_auth && !agg_config.require_tls) { + RCLCPP_WARN(get_logger(), + "Aggregation: forward_auth is enabled but require_tls is false. " + "Authorization tokens may be sent to peers over cleartext HTTP. " + "Set aggregation.require_tls=true for production deployments."); + } + + auto logger = get_logger(); + aggregation_mgr_ = std::make_unique(agg_config, &logger); + + // mDNS discovery/announcement + if (agg_config.announce || agg_config.discover) { + MdnsDiscovery::Config mdns_config; + mdns_config.announce = agg_config.announce; + mdns_config.discover = agg_config.discover; + mdns_config.service = agg_config.mdns_service; + mdns_config.port = server_port_; + mdns_config.peer_scheme = agg_config.peer_scheme; + // mDNS instance name must be unique per gateway instance. + // Defaults to hostname via MdnsDiscovery constructor when empty. + // Operators should set aggregation.mdns_name for multi-gateway-per-host deployments. + mdns_config.name = get_parameter("aggregation.mdns_name").as_string(); + mdns_config.on_error = [this](const std::string & msg) { + RCLCPP_ERROR(get_logger(), "mDNS: %s", msg.c_str()); + }; + mdns_config.on_log = [this](const std::string & msg) { + RCLCPP_DEBUG(get_logger(), "mDNS: %s", msg.c_str()); + }; + mdns_discovery_ = std::make_unique(mdns_config); + // Get the actual mDNS instance name for self-discovery filtering. + // MdnsDiscovery constructor resolves empty name to gethostname(). + // Sanitize it the same way browse_callback sanitizes discovered peer names. + const std::string self_mdns_name = HostInfoProvider::sanitize_entity_id(mdns_discovery_->instance_name()); + + // Collect local interface addresses at startup for IP-based self-discovery + // filtering. Name-only checks are insufficient: an attacker can send mDNS + // responses with a different name but our own IP:port, creating forwarding loops. + auto local_addrs = std::make_shared>(collect_local_addresses()); + const int self_port = server_port_; + + mdns_discovery_->start( + [this, self_mdns_name, local_addrs, self_port](const std::string & url, const std::string & name) { + if (name == self_mdns_name) { + return; // Skip self-discovery (name match) + } + // Also reject peers whose resolved IP:port matches our own listen address. + // Prevents forwarding loops from spoofed mDNS responses. + auto [peer_host, peer_port] = parse_url_host_port(url); + if (peer_port == self_port && local_addrs->count(peer_host) > 0) { + RCLCPP_WARN(get_logger(), + "mDNS: rejecting peer '%s' at %s - resolves to local address with our port " + "(possible spoofed mDNS response)", + name.c_str(), url.c_str()); + return; + } + aggregation_mgr_->add_discovered_peer(url, name); + }, + [this](const std::string & name) { + aggregation_mgr_->remove_discovered_peer(name); + }); + } + + RCLCPP_INFO(get_logger(), + "Aggregation: enabled (timeout=%dms, announce=%s, discover=%s, " + "forward_auth=%s, require_tls=%s, peer_scheme=%s, max_discovered_peers=%zu)", + agg_config.timeout_ms, agg_config.announce ? "true" : "false", agg_config.discover ? "true" : "false", + agg_config.forward_auth ? "true" : "false", agg_config.require_tls ? "true" : "false", + agg_config.peer_scheme.c_str(), agg_config.max_discovered_peers); + } else { + RCLCPP_INFO(get_logger(), "Aggregation: disabled"); + } + // Register built-in resource samplers sampler_registry_->register_sampler( "data", @@ -1140,6 +1276,11 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki rest_server_->set_trigger_handlers(*trigger_mgr_); } + // Wire aggregation manager into REST server handler context + if (aggregation_mgr_) { + rest_server_->set_aggregation_manager(aggregation_mgr_.get()); + } + start_rest_server(); std::string protocol = tls_config_.enabled ? "HTTPS" : "HTTP"; @@ -1149,7 +1290,12 @@ GatewayNode::GatewayNode(const rclcpp::NodeOptions & options) : Node("ros2_medki GatewayNode::~GatewayNode() { RCLCPP_INFO(get_logger(), "Shutting down ROS 2 Medkit Gateway..."); + // 0. Stop mDNS discovery first (prevents new peer additions during shutdown) + if (mdns_discovery_) { + mdns_discovery_->stop(); + } // 1. Stop REST server (kills HTTP connections, SSE streams exit) + // Handlers may reference aggregation_mgr_, so it must outlive the server. stop_rest_server(); // 2. Shutdown subscriptions via transport registry (calls sub_mgr.shutdown(), // which triggers on_removed -> transport->stop() for each active subscription) @@ -1262,6 +1408,10 @@ ConditionRegistry * GatewayNode::get_condition_registry() const { return condition_registry_.get(); } +AggregationManager * GatewayNode::get_aggregation_manager() const { + return aggregation_mgr_.get(); +} + void GatewayNode::refresh_cache() { RCLCPP_DEBUG(get_logger(), "Refreshing entity cache..."); @@ -1273,19 +1423,52 @@ void GatewayNode::refresh_cache() { // in RUNTIME_ONLY mode we manually merge node + topic components auto areas = discovery_mgr_->discover_areas(); auto apps = discovery_mgr_->discover_apps(); - auto functions = discovery_mgr_->discover_functions(); - - std::vector all_components; - if (discovery_mgr_->get_mode() == DiscoveryMode::RUNTIME_ONLY) { - // RUNTIME_ONLY: merge node + topic components (no pipeline) - auto node_components = discovery_mgr_->discover_components(); - auto topic_components = discovery_mgr_->discover_topic_components(); - all_components.reserve(node_components.size() + topic_components.size()); - all_components.insert(all_components.end(), node_components.begin(), node_components.end()); - all_components.insert(all_components.end(), topic_components.begin(), topic_components.end()); - } else { - // HYBRID: pipeline merges all sources; MANIFEST_ONLY: manifest components only - all_components = discovery_mgr_->discover_components(); + auto functions = discovery_mgr_->discover_functions(apps); + + // In RUNTIME_ONLY mode: HostInfoProvider component or empty. + // In HYBRID/MANIFEST_ONLY: pipeline-merged or manifest components. + auto all_components = discovery_mgr_->discover_components(); + + // Link Apps to default Component (is-located-on relationship) + // In RUNTIME_ONLY mode with host info provider, ALL apps belong to + // the single host-derived Component. Override any namespace-derived + // component_id since those synthetic components no longer exist. + // In MANIFEST_ONLY and HYBRID modes, apps keep their manifest-assigned + // component_id (is_located_on relationship). + if (discovery_mgr_->get_mode() == DiscoveryMode::RUNTIME_ONLY && discovery_mgr_->has_host_info_provider()) { + auto default_comp = discovery_mgr_->get_default_component(); + if (default_comp) { + for (auto & app : apps) { + app.component_id = default_comp->id; + } + } + } + + // Merge remote peer entities if aggregation is active. + // Keep the routing table accessible for the internal-node filter below. + std::unordered_map peer_routing_table; + if (aggregation_mgr_ && aggregation_mgr_->peer_count() > 0) { + aggregation_mgr_->check_all_health(); + auto logger = get_logger(); + auto merged = + aggregation_mgr_->fetch_and_merge_peer_entities(areas, all_components, apps, functions, 10000, &logger); + areas = std::move(merged.areas); + all_components = std::move(merged.components); + apps = std::move(merged.apps); + functions = std::move(merged.functions); + peer_routing_table = std::move(merged.routing_table); + aggregation_mgr_->update_routing_table(peer_routing_table); + } + + // Filter ROS 2 internal nodes (underscore prefix convention). + // Controlled by discovery.runtime.filter_internal_nodes parameter (default: true). + // Covers local heuristic apps (which bypass the merge pipeline orphan filter + // in runtime_only mode) and any peer apps that slipped through fetch_entities. + if (filter_internal_nodes_) { + auto removed = filter_internal_node_apps(apps, peer_routing_table); + if (removed > 0) { + RCLCPP_DEBUG(get_logger(), "Filtered %zu internal node apps (_ prefix)", removed); + } } // Capture sizes for logging @@ -1374,4 +1557,25 @@ void GatewayNode::stop_rest_server() { } } +size_t filter_internal_node_apps(std::vector & apps, + const std::unordered_map & peer_routing_table) { + auto before = apps.size(); + auto end = std::remove_if(apps.begin(), apps.end(), [&peer_routing_table](const App & app) { + std::string original_id = app.id; + auto rt_it = peer_routing_table.find(app.id); + if (rt_it != peer_routing_table.end()) { + // Known remote entity: strip "peer_name__" prefix if present + const std::string & peer_name = rt_it->second; + std::string prefix = peer_name + "__"; + if (original_id.size() > prefix.size() && original_id.compare(0, prefix.size(), prefix) == 0) { + original_id = original_id.substr(prefix.size()); + } + } + // ROS 2 internal nodes use _ prefix convention + return !original_id.empty() && original_id[0] == '_'; + }); + apps.erase(end, apps.end()); + return before - apps.size(); +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp index 3e076520b..8c145afaa 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/bulkdata_handlers.cpp @@ -42,7 +42,7 @@ void BulkDataHandlers::handle_list_categories(const httplib::Request & req, http // Validate entity exists and matches the route type (e.g., /components/ only accepts components) auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_info->entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Validate entity type supports bulk-data collection (SOVD Table 8) @@ -78,7 +78,7 @@ void BulkDataHandlers::handle_list_descriptors(const httplib::Request & req, htt // Validate entity exists and matches the route type auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_info->entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity = *entity_opt; @@ -201,7 +201,7 @@ void BulkDataHandlers::handle_upload(const httplib::Request & req, httplib::Resp // Validate entity exists and matches the route type auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_info->entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Validate entity type supports bulk-data collection (SOVD Table 8) @@ -321,7 +321,7 @@ void BulkDataHandlers::handle_delete(const httplib::Request & req, httplib::Resp // Validate entity exists and matches the route type auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_info->entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Validate entity type supports bulk-data collection (SOVD Table 8) @@ -392,7 +392,7 @@ void BulkDataHandlers::handle_download(const httplib::Request & req, httplib::Re // Validate entity exists and matches the route type auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_info->entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity = *entity_opt; diff --git a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp index e3bea2084..564062d46 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/config_handlers.cpp @@ -223,7 +223,7 @@ void ConfigHandlers::handle_list_configurations(const httplib::Request & req, ht // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Get aggregated configurations info for this entity @@ -364,7 +364,7 @@ void ConfigHandlers::handle_get_configuration(const httplib::Request & req, http // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Parameter ID may be prefixed with app_id: for aggregated configs @@ -525,7 +525,7 @@ void ConfigHandlers::handle_set_configuration(const httplib::Request & req, http // Now validate entity exists and matches route type auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Check lock access for configurations @@ -623,7 +623,7 @@ void ConfigHandlers::handle_delete_configuration(const httplib::Request & req, h // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Check lock access for configurations @@ -705,7 +705,7 @@ void ConfigHandlers::handle_delete_all_configurations(const httplib::Request & r // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Check lock access for configurations diff --git a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp index ad45c288e..1949f9de0 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/data_handlers.cpp @@ -40,7 +40,7 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -93,7 +93,8 @@ void DataHandlers::handle_list_data(const httplib::Request & req, httplib::Respo XMedkit resp_ext; resp_ext.entity_id(entity_id).add("total_count", items.size()); if (aggregated.is_aggregated) { - resp_ext.add("aggregated_from", aggregated.source_ids); + resp_ext.add("aggregated", true); + resp_ext.add("aggregation_sources", aggregated.source_ids); resp_ext.add("aggregation_level", aggregated.aggregation_level); } response["x-medkit"] = resp_ext.build(); @@ -123,7 +124,7 @@ void DataHandlers::handle_get_data_item(const httplib::Request & req, httplib::R // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Determine the full ROS topic path @@ -204,7 +205,7 @@ void DataHandlers::handle_put_data_item(const httplib::Request & req, httplib::R // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } // Check lock access for data diff --git a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp index 2e68335ef..53c3556f8 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/discovery_handlers.cpp @@ -68,6 +68,12 @@ void DiscoveryHandlers::handle_list_areas(const httplib::Request & req, httplib: json items = json::array(); for (const auto & area : areas) { + // Subareas (with parent_area_id) are only visible via + // GET /areas/{id}/subareas, not in the top-level list. + if (!area.parent_area_id.empty()) { + continue; + } + json area_item; area_item["id"] = area.id; area_item["name"] = area.name.empty() ? area.id : area.name; @@ -82,9 +88,6 @@ void DiscoveryHandlers::handle_list_areas(const httplib::Request & req, httplib: XMedkit ext; ext.ros2_namespace(area.namespace_path); - if (!area.parent_area_id.empty()) { - ext.add("parent_area_id", area.parent_area_id); - } area_item["x-medkit"] = ext.build(); items.push_back(area_item); @@ -120,8 +123,13 @@ void DiscoveryHandlers::handle_get_area(const httplib::Request & req, httplib::R return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto area_opt = cache.get_area(area_id); + if (!area_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + area_opt = discovery->get_area(area_id); + } if (!area_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Area not found", {{"area_id", area_id}}); @@ -257,23 +265,29 @@ void DiscoveryHandlers::handle_get_subareas(const httplib::Request & req, httpli return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto area_opt = cache.get_area(area_id); + if (!area_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + area_opt = discovery->get_area(area_id); + } if (!area_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Area not found", {{"area_id", area_id}}); return; } + // Use cache relationship index for subarea IDs, then look up each auto subarea_ids = cache.get_subareas(area_id); json items = json::array(); - for (const auto & sa_id : subarea_ids) { - auto sa_opt = cache.get_area(sa_id); - if (!sa_opt) { + for (const auto & sub_id : subarea_ids) { + auto subarea_opt = cache.get_area(sub_id); + if (!subarea_opt) { continue; } - const auto & subarea = *sa_opt; + const auto & subarea = *subarea_opt; json item; item["id"] = subarea.id; @@ -323,20 +337,47 @@ void DiscoveryHandlers::handle_get_contains(const httplib::Request & req, httpli return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto area_opt = cache.get_area(area_id); + if (!area_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + area_opt = discovery->get_area(area_id); + } if (!area_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Area not found", {{"area_id", area_id}}); return; } - // Use DiscoveryManager for contains - it resolves subarea hierarchy - auto discovery = ctx_.node()->get_discovery_manager(); - auto components = discovery->get_components_for_area(area_id); + // Recursively collect components from this area and all descendant subareas + // (mirrors ManifestManager::get_components_for_area behavior) + std::vector area_queue = {area_id}; + std::set visited_areas; + std::vector all_comp_ids; + + while (!area_queue.empty()) { + auto current_area = area_queue.back(); + area_queue.pop_back(); + if (!visited_areas.insert(current_area).second) { + continue; + } + + auto comp_ids = cache.get_components_for_area(current_area); + all_comp_ids.insert(all_comp_ids.end(), comp_ids.begin(), comp_ids.end()); + + auto sub_ids = cache.get_subareas(current_area); + area_queue.insert(area_queue.end(), sub_ids.begin(), sub_ids.end()); + } json items = json::array(); - for (const auto & comp : components) { + for (const auto & comp_id : all_comp_ids) { + auto comp_opt = cache.get_component(comp_id); + if (!comp_opt) { + continue; + } + const auto & comp = *comp_opt; + json item; item["id"] = comp.id; item["name"] = comp.name.empty() ? comp.id : comp.name; @@ -384,6 +425,12 @@ void DiscoveryHandlers::handle_list_components(const httplib::Request & req, htt json items = json::array(); for (const auto & component : components) { + // Subcomponents (with parent_component_id) are only visible via + // GET /components/{id}/subcomponents, not in the top-level list. + if (!component.parent_component_id.empty()) { + continue; + } + json item; item["id"] = component.id; item["name"] = component.name.empty() ? component.id : component.name; @@ -439,8 +486,14 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl return; } + // Read from cache first (has merged entities from aggregation), + // fall back to discovery manager for entities not yet cached. const auto & cache = ctx_.node()->get_thread_safe_cache(); auto comp_opt = cache.get_component(component_id); + if (!comp_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + comp_opt = discovery->get_component(component_id); + } if (!comp_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Component not found", @@ -506,6 +559,21 @@ void DiscoveryHandlers::handle_get_component(const httplib::Request & req, httpl if (!comp.type.empty()) { ext.add("type", comp.type); } + if (!comp.parent_component_id.empty()) { + ext.add("parentComponentId", comp.parent_component_id); + } + if (!comp.depends_on.empty()) { + ext.add("dependsOn", nlohmann::json(comp.depends_on)); + } + if (!comp.area.empty()) { + ext.add("area", comp.area); + } + if (!comp.variant.empty()) { + ext.add("variant", comp.variant); + } + if (!comp.description.empty()) { + ext.add("description", comp.description); + } using Cap = CapabilityBuilder::Capability; std::vector caps = { @@ -551,8 +619,13 @@ void DiscoveryHandlers::handle_get_subcomponents(const httplib::Request & req, h return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto comp_opt = cache.get_component(component_id); + if (!comp_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + comp_opt = discovery->get_component(component_id); + } if (!comp_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Component not found", @@ -560,11 +633,15 @@ void DiscoveryHandlers::handle_get_subcomponents(const httplib::Request & req, h return; } - auto discovery = ctx_.node()->get_discovery_manager(); - auto subcomponents = discovery->get_subcomponents(component_id); + // Cache has no get_subcomponents(), so filter from all components + const auto all_components = cache.get_components(); json items = json::array(); - for (const auto & sub : subcomponents) { + for (const auto & sub : all_components) { + if (sub.parent_component_id != component_id) { + continue; + } + json item; item["id"] = sub.id; item["name"] = sub.name.empty() ? sub.id : sub.name; @@ -616,8 +693,13 @@ void DiscoveryHandlers::handle_get_hosts(const httplib::Request & req, httplib:: return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto comp_opt = cache.get_component(component_id); + if (!comp_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + comp_opt = discovery->get_component(component_id); + } if (!comp_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Component not found", @@ -625,12 +707,17 @@ void DiscoveryHandlers::handle_get_hosts(const httplib::Request & req, httplib:: return; } - // Use DiscoveryManager for hosts - returns full App objects with online status - auto discovery = ctx_.node()->get_discovery_manager(); - auto apps = discovery->get_apps_for_component(component_id); + // Use cache relationship index for app IDs, then look up each + auto app_ids = cache.get_apps_for_component(component_id); json items = json::array(); - for (const auto & app : apps) { + for (const auto & aid : app_ids) { + auto app_opt = cache.get_app(aid); + if (!app_opt) { + continue; + } + const auto & app = *app_opt; + json item; item["id"] = app.id; item["name"] = app.name.empty() ? app.id : app.name; @@ -681,8 +768,13 @@ void DiscoveryHandlers::handle_component_depends_on(const httplib::Request & req return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto comp_opt = cache.get_component(component_id); + if (!comp_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + comp_opt = discovery->get_component(component_id); + } if (!comp_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Component not found", @@ -805,8 +897,14 @@ void DiscoveryHandlers::handle_get_app(const httplib::Request & req, httplib::Re return; } + // Read from cache first (has host component override applied), + // fall back to discovery manager for entities not yet cached. const auto & cache = ctx_.node()->get_thread_safe_cache(); auto app_opt = cache.get_app(app_id); + if (!app_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + app_opt = discovery->get_app(app_id); + } if (!app_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "App not found", {{"app_id", app_id}}); @@ -911,8 +1009,13 @@ void DiscoveryHandlers::handle_app_depends_on(const httplib::Request & req, http return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto app_opt = cache.get_app(app_id); + if (!app_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + app_opt = discovery->get_app(app_id); + } if (!app_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "App not found", {{"app_id", app_id}}); @@ -981,8 +1084,13 @@ void DiscoveryHandlers::handle_app_is_located_on(const httplib::Request & req, h return; } + // Cache-first lookup: EntityCache has merged entities from peers const auto & cache = ctx_.node()->get_thread_safe_cache(); auto app_opt = cache.get_app(app_id); + if (!app_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + app_opt = discovery->get_app(app_id); + } if (!app_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "App not found", {{"app_id", app_id}}); @@ -994,6 +1102,10 @@ void DiscoveryHandlers::handle_app_is_located_on(const httplib::Request & req, h if (!app.component_id.empty()) { auto component_opt = cache.get_component(app.component_id); + if (!component_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + component_opt = discovery->get_component(app.component_id); + } if (component_opt) { json item; item["id"] = component_opt->id; @@ -1100,6 +1212,10 @@ void DiscoveryHandlers::handle_get_function(const httplib::Request & req, httpli const auto & cache = ctx_.node()->get_thread_safe_cache(); auto func_opt = cache.get_function(function_id); + if (!func_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + func_opt = discovery->get_function(function_id); + } if (!func_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Function not found", {{"function_id", function_id}}); @@ -1154,6 +1270,12 @@ void DiscoveryHandlers::handle_get_function(const httplib::Request & req, httpli XMedkit ext; ext.source(func.source); + if (!func.hosts.empty()) { + ext.add("hosts", nlohmann::json(func.hosts)); + } + if (!func.description.empty()) { + ext.add("description", func.description); + } response["x-medkit"] = ext.build(); HandlerContext::send_json(res, response); @@ -1179,16 +1301,21 @@ void DiscoveryHandlers::handle_function_hosts(const httplib::Request & req, http return; } + // Read from cache (has merged entities from aggregation with combined hosts) const auto & cache = ctx_.node()->get_thread_safe_cache(); auto func_opt = cache.get_function(function_id); + if (!func_opt) { + auto discovery = ctx_.node()->get_discovery_manager(); + func_opt = discovery->get_function(function_id); + } if (!func_opt) { HandlerContext::send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Function not found", {{"function_id", function_id}}); return; } - auto discovery = ctx_.node()->get_discovery_manager(); - auto host_ids = discovery->get_hosts_for_function(function_id); + // Use the Function's hosts list directly (includes merged hosts from all peers) + const auto & host_ids = func_opt->hosts; json items = json::array(); for (const auto & app_id : host_ids) { diff --git a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp index 7ff8d5bf9..ad1c32057 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/fault_handlers.cpp @@ -23,6 +23,7 @@ #include #include +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/entity_path_utils.hpp" #include "ros2_medkit_gateway/http/error_codes.hpp" @@ -264,6 +265,21 @@ void FaultHandlers::handle_list_all_faults(const httplib::Request & req, httplib ext.add("clusters", result.data["clusters"]); } + // Fan-out to peers: faults are managed by FaultManager, not cached, + // so we need to query each peer's /faults endpoint and merge results. + if (auto * agg = ctx_.aggregation_manager()) { + auto fan_result = agg->fan_out_get(req.path, req.get_header_value("Authorization")); + if (fan_result.merged_items.is_array()) { + for (const auto & item : fan_result.merged_items) { + response["items"].push_back(item); + } + } + if (fan_result.is_partial) { + ext.add("partial", true); + ext.add("failed_peers", fan_result.failed_peers); + } + } + if (!ext.empty()) { response["x-medkit"] = ext.build(); } @@ -293,7 +309,7 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -345,8 +361,15 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re XMedkit ext; ext.entity_id(entity_id); ext.add("aggregation_level", "function"); + ext.add("aggregated", true); ext.add("count", filtered_faults.size()); ext.add("host_count", host_fqns.size()); + // Include source app IDs for cross-referencing aggregated results + json source_ids = json::array(); + for (const auto & fqn : host_fqns) { + source_ids.push_back(fqn); + } + ext.add("aggregation_sources", source_ids); response["x-medkit"] = ext.build(); HandlerContext::send_json(res, response); @@ -389,15 +412,77 @@ void FaultHandlers::handle_list_faults(const httplib::Request & req, httplib::Re XMedkit ext; ext.entity_id(entity_id); ext.add("aggregation_level", "component"); + ext.add("aggregated", true); + ext.add("count", filtered_faults.size()); + ext.add("app_count", app_fqns.size()); + // Include source app FQNs for cross-referencing aggregated results + json source_fqns = json::array(); + for (const auto & fqn : app_fqns) { + source_fqns.push_back(fqn); + } + ext.add("aggregation_sources", source_fqns); + + response["x-medkit"] = ext.build(); + HandlerContext::send_json(res, response); + return; + } + + // For Areas, aggregate faults from all apps in all components within the area + // This is an x-medkit extension - SOVD spec does not define fault collections for Areas + if (entity_info.type == EntityType::AREA) { + // Get all faults (no namespace filter) + auto result = fault_mgr->list_faults("", filter.include_pending, filter.include_confirmed, filter.include_cleared, + filter.include_healed, include_muted, include_clusters); + + if (!result.success) { + HandlerContext::send_error(res, 503, ERR_SERVICE_UNAVAILABLE, "Failed to get faults", + {{"details", result.error_message}, {entity_info.id_field, entity_id}}); + return; + } + + // Collect FQNs from all apps in all components belonging to this area + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto comp_ids = cache.get_components_for_area(entity_id); + std::set app_fqns; + for (const auto & comp_id : comp_ids) { + auto app_ids = cache.get_apps_for_component(comp_id); + for (const auto & app_id : app_ids) { + auto app = cache.get_app(app_id); + if (app) { + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + app_fqns.insert(std::move(fqn)); + } + } + } + } + + // Filter faults to only those from the area's apps + json filtered_faults = filter_faults_by_sources(result.data["faults"], app_fqns); + + // Build response + json response = {{"items", filtered_faults}}; + + XMedkit ext; + ext.entity_id(entity_id); + ext.add("aggregation_level", "area"); + ext.add("aggregated", true); ext.add("count", filtered_faults.size()); + ext.add("component_count", comp_ids.size()); ext.add("app_count", app_fqns.size()); + // Include source app FQNs for cross-referencing aggregated results + json area_source_fqns = json::array(); + for (const auto & fqn : app_fqns) { + area_source_fqns.push_back(fqn); + } + ext.add("aggregation_sources", area_source_fqns); response["x-medkit"] = ext.build(); HandlerContext::send_json(res, response); return; } - // For other entity types (App, Area), use namespace_path filtering + // For Apps, use namespace_path filtering std::string namespace_path = entity_info.namespace_path; auto result = fault_mgr->list_faults(namespace_path, filter.include_pending, filter.include_confirmed, filter.include_cleared, @@ -459,7 +544,7 @@ void FaultHandlers::handle_get_fault(const httplib::Request & req, httplib::Resp // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -518,7 +603,7 @@ void FaultHandlers::handle_clear_fault(const httplib::Request & req, httplib::Re // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -574,7 +659,7 @@ void FaultHandlers::handle_clear_all_faults(const httplib::Request & req, httpli // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; diff --git a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp index 37a6c8b56..c5c272ad0 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/handler_context.cpp @@ -14,6 +14,7 @@ #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/lock_manager.hpp" @@ -78,6 +79,18 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn EntityInfo info; info.id = entity_id; + // Helper: check routing table for remote entity metadata + auto apply_routing = [this](EntityInfo & ei) { + if (aggregation_mgr_) { + auto peer = aggregation_mgr_->find_peer_for_entity(ei.id); + if (peer) { + ei.is_remote = true; + ei.peer_name = *peer; + ei.peer_url = aggregation_mgr_->get_peer_url(*peer); + } + } + }; + // If expected_type is specified, search ONLY in that collection // This prevents ID collisions (e.g., Area "powertrain" vs Component "powertrain") if (expected_type != SovdEntityType::UNKNOWN) { @@ -89,6 +102,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = component->fqn; info.id_field = "component_id"; info.error_name = "Component"; + apply_routing(info); return info; } break; @@ -101,6 +115,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = fqn; info.id_field = "app_id"; info.error_name = "App"; + apply_routing(info); return info; } break; @@ -112,6 +127,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = area->namespace_path; info.id_field = "area_id"; info.error_name = "Area"; + apply_routing(info); return info; } break; @@ -123,6 +139,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = ""; info.id_field = "function_id"; info.error_name = "Function"; + apply_routing(info); return info; } break; @@ -145,6 +162,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = component->fqn; info.id_field = "component_id"; info.error_name = "Component"; + apply_routing(info); return info; } @@ -156,6 +174,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = fqn; info.id_field = "app_id"; info.error_name = "App"; + apply_routing(info); return info; } @@ -166,6 +185,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = area->namespace_path; info.id_field = "area_id"; info.error_name = "Area"; + apply_routing(info); return info; } @@ -176,6 +196,7 @@ EntityInfo HandlerContext::get_entity_info(const std::string & entity_id, SovdEn info.fqn = ""; info.id_field = "function_id"; info.error_name = "Function"; + apply_routing(info); return info; } @@ -235,15 +256,14 @@ std::optional HandlerContext::validate_lock_access(const httplib::R return std::nullopt; } -std::optional HandlerContext::validate_entity_for_route(const httplib::Request & req, - httplib::Response & res, - const std::string & entity_id) const { +ValidateResult HandlerContext::validate_entity_for_route(const httplib::Request & req, httplib::Response & res, + const std::string & entity_id) const { // Step 1: Validate entity ID format auto validation_result = validate_entity_id(entity_id); if (!validation_result) { send_error(res, 400, ERR_INVALID_PARAMETER, "Invalid entity ID", {{"details", validation_result.error()}, {"entity_id", entity_id}}); - return std::nullopt; + return tl::unexpected(ValidationOutcome::kErrorSent); } // Step 2: Get expected type from route path and look up entity @@ -265,7 +285,13 @@ std::optional HandlerContext::validate_entity_for_route(const httpli // Entity doesn't exist at all -> 404 send_error(res, 404, ERR_ENTITY_NOT_FOUND, "Entity not found", {{"entity_id", entity_id}}); } - return std::nullopt; + return tl::unexpected(ValidationOutcome::kErrorSent); + } + + // Step 4: Forward to peer if entity is remote + if (entity_info.is_remote && aggregation_mgr_) { + aggregation_mgr_->forward_request(entity_info.peer_name, req, res); + return tl::unexpected(ValidationOutcome::kForwarded); } return entity_info; diff --git a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp index 240cb7dcb..7c885ef22 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/health_handlers.cpp @@ -17,6 +17,7 @@ #include #include +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/auth/auth_models.hpp" #include "ros2_medkit_gateway/discovery/discovery_enums.hpp" #include "ros2_medkit_gateway/discovery/discovery_manager.hpp" @@ -63,6 +64,11 @@ void HealthHandlers::handle_health(const httplib::Request & req, httplib::Respon response["discovery"] = std::move(discovery_info); } + // Add peer status when aggregation is active + if (auto * agg = ctx_.aggregation_manager()) { + response["peers"] = agg->get_peer_status(); + } + HandlerContext::send_json(res, response); } catch (const std::exception & e) { HandlerContext::send_error(res, 500, ERR_INTERNAL_ERROR, "Internal server error"); diff --git a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp index 5c648d24c..decfbf996 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/log_handlers.cpp @@ -17,9 +17,11 @@ #include #include #include +#include #include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/error_codes.hpp" +#include "ros2_medkit_gateway/http/x_medkit.hpp" namespace ros2_medkit_gateway { namespace handlers { @@ -72,11 +74,17 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons // Functions aggregate logs from all hosted apps. if (entity.type == EntityType::FUNCTION) { // Aggregate logs from all hosted apps + // This is an x-medkit extension - SOVD spec defines only data/operations for Functions const auto & cache = ctx_.node()->get_thread_safe_cache(); auto func = cache.get_function(entity_id); if (!func || func->hosts.empty()) { json result; result["items"] = json::array(); + XMedkit ext; + ext.entity_id(entity_id); + ext.add("aggregation_level", "function"); + ext.add("aggregated", true); + result["x-medkit"] = ext.build(); HandlerContext::send_json(res, result); return; } @@ -98,6 +106,11 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons if (host_fqns.empty()) { json result; result["items"] = json::array(); + XMedkit ext; + ext.entity_id(entity_id); + ext.add("aggregation_level", "function"); + ext.add("aggregated", true); + result["x-medkit"] = ext.build(); HandlerContext::send_json(res, result); return; } @@ -111,10 +124,75 @@ void LogHandlers::handle_get_logs(const httplib::Request & req, httplib::Respons json result; result["items"] = std::move(*logs); + + XMedkit ext; + ext.entity_id(entity_id); + ext.add("aggregation_level", "function"); + ext.add("aggregated", true); + ext.add("host_count", host_fqns.size()); + // Include source app FQNs for cross-referencing aggregated results + nlohmann::json log_source_fqns = nlohmann::json::array(); + for (const auto & fqn : host_fqns) { + log_source_fqns.push_back(fqn); + } + ext.add("aggregation_sources", log_source_fqns); + result["x-medkit"] = ext.build(); + HandlerContext::send_json(res, result); return; } + // For Areas, aggregate logs from all apps in all components within the area + // This is an x-medkit extension - SOVD spec does not define log collections for Areas + if (entity.type == EntityType::AREA) { + const auto & cache = ctx_.node()->get_thread_safe_cache(); + auto comp_ids = cache.get_components_for_area(entity_id); + + // Collect FQNs from all apps in all components belonging to this area + std::vector host_fqns; + for (const auto & comp_id : comp_ids) { + auto app_ids = cache.get_apps_for_component(comp_id); + for (const auto & app_id : app_ids) { + auto app = cache.get_app(app_id); + if (!app) { + continue; + } + auto fqn = app->effective_fqn(); + if (!fqn.empty()) { + host_fqns.push_back(std::move(fqn)); + } + } + } + + if (!host_fqns.empty()) { + auto logs = log_mgr->get_logs(host_fqns, false, min_severity, context_filter, entity_id); + if (!logs) { + HandlerContext::send_error(res, 503, ERR_SERVICE_UNAVAILABLE, logs.error()); + return; + } + + json result; + result["items"] = std::move(*logs); + + XMedkit ext; + ext.entity_id(entity_id); + ext.add("aggregation_level", "area"); + ext.add("aggregated", true); + ext.add("component_count", comp_ids.size()); + ext.add("app_count", host_fqns.size()); + nlohmann::json area_log_source_fqns = nlohmann::json::array(); + for (const auto & fqn : host_fqns) { + area_log_source_fqns.push_back(fqn); + } + ext.add("aggregation_sources", area_log_source_fqns); + result["x-medkit"] = ext.build(); + + HandlerContext::send_json(res, result); + return; + } + // No components linked to area - fall through to namespace prefix matching + } + const bool prefix_match = (entity.type == EntityType::COMPONENT || entity.type == EntityType::AREA); auto logs = log_mgr->get_logs({entity.fqn}, prefix_match, min_severity, context_filter, entity_id); diff --git a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp index 33e510fe9..6f08c2b26 100644 --- a/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp +++ b/src/ros2_medkit_gateway/src/http/handlers/operation_handlers.cpp @@ -40,7 +40,7 @@ void OperationHandlers::handle_list_operations(const httplib::Request & req, htt // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -174,7 +174,7 @@ void OperationHandlers::handle_get_operation(const httplib::Request & req, httpl // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; @@ -340,7 +340,7 @@ void OperationHandlers::handle_create_execution(const httplib::Request & req, ht // Validate entity ID and type for this route auto entity_opt = ctx_.validate_entity_for_route(req, res, entity_id); if (!entity_opt) { - return; // Error response already sent + return; // Response already sent (error or forwarded to peer) } auto entity_info = *entity_opt; diff --git a/src/ros2_medkit_gateway/src/http/rest_server.cpp b/src/ros2_medkit_gateway/src/http/rest_server.cpp index 55e887f95..09f0cc388 100644 --- a/src/ros2_medkit_gateway/src/http/rest_server.cpp +++ b/src/ros2_medkit_gateway/src/http/rest_server.cpp @@ -266,6 +266,10 @@ void RESTServer::set_trigger_handlers(TriggerManager & trigger_mgr) { trigger_handlers_ = std::make_unique(*handler_ctx_, trigger_mgr, sse_client_tracker_); } +void RESTServer::set_aggregation_manager(AggregationManager * mgr) { + handler_ctx_->set_aggregation_manager(mgr); +} + RESTServer::~RESTServer() { stop(); } diff --git a/src/ros2_medkit_gateway/src/models/aggregation_service.cpp b/src/ros2_medkit_gateway/src/models/aggregation_service.cpp index 5c90c0d0a..96a5b03f2 100644 --- a/src/ros2_medkit_gateway/src/models/aggregation_service.cpp +++ b/src/ros2_medkit_gateway/src/models/aggregation_service.cpp @@ -92,4 +92,74 @@ bool AggregationService::should_aggregate(SovdEntityType type) { } } +std::vector AggregationService::get_child_app_ids(SovdEntityType type, + const std::string & entity_id) const { + switch (type) { + case SovdEntityType::APP: + return {entity_id}; + + case SovdEntityType::COMPONENT: + return cache_->get_apps_for_component(entity_id); + + case SovdEntityType::FUNCTION: { + auto func = cache_->get_function(entity_id); + if (!func) { + return {}; + } + return func->hosts; + } + + case SovdEntityType::AREA: { + std::vector app_ids; + auto comp_ids = cache_->get_components_for_area(entity_id); + for (const auto & comp_id : comp_ids) { + auto comp_apps = cache_->get_apps_for_component(comp_id); + app_ids.insert(app_ids.end(), comp_apps.begin(), comp_apps.end()); + } + return app_ids; + } + + case SovdEntityType::SERVER: + case SovdEntityType::UNKNOWN: + default: + return {}; + } +} + +nlohmann::json AggregationService::build_collection_x_medkit(SovdEntityType type, const std::string & entity_id) const { + nlohmann::json x_medkit; + bool aggregated = should_aggregate(type); + x_medkit["aggregated"] = aggregated; + + if (aggregated) { + auto child_ids = get_child_app_ids(type, entity_id); + if (!child_ids.empty()) { + x_medkit["aggregation_sources"] = child_ids; + } + } + + // Set aggregation_level + switch (type) { + case SovdEntityType::APP: + x_medkit["aggregation_level"] = "app"; + break; + case SovdEntityType::COMPONENT: + x_medkit["aggregation_level"] = "component"; + break; + case SovdEntityType::AREA: + x_medkit["aggregation_level"] = "area"; + break; + case SovdEntityType::FUNCTION: + x_medkit["aggregation_level"] = "function"; + break; + case SovdEntityType::SERVER: + x_medkit["aggregation_level"] = "server"; + break; + default: + break; + } + + return x_medkit; +} + } // namespace ros2_medkit_gateway diff --git a/src/ros2_medkit_gateway/src/vendored/mdns/LICENSE b/src/ros2_medkit_gateway/src/vendored/mdns/LICENSE new file mode 100644 index 000000000..cf1ab25da --- /dev/null +++ b/src/ros2_medkit_gateway/src/vendored/mdns/LICENSE @@ -0,0 +1,24 @@ +This is free and unencumbered software released into the public domain. + +Anyone is free to copy, modify, publish, use, compile, sell, or +distribute this software, either in source code form or as a compiled +binary, for any purpose, commercial or non-commercial, and by any +means. + +In jurisdictions that recognize copyright laws, the author or authors +of this software dedicate any and all copyright interest in the +software to the public domain. We make this dedication for the benefit +of the public at large and to the detriment of our heirs and +successors. We intend this dedication to be an overt act of +relinquishment in perpetuity of all present and future rights to this +software under copyright law. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. +IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR +OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR +OTHER DEALINGS IN THE SOFTWARE. + +For more information, please refer to diff --git a/src/ros2_medkit_gateway/src/vendored/mdns/mdns.h b/src/ros2_medkit_gateway/src/vendored/mdns/mdns.h new file mode 100644 index 000000000..c9c5fbb57 --- /dev/null +++ b/src/ros2_medkit_gateway/src/vendored/mdns/mdns.h @@ -0,0 +1,1621 @@ +/* mdns.h - mDNS/DNS-SD library - Public Domain - 2017 Mattias Jansson + * + * This library provides a cross-platform mDNS and DNS-SD library in C. + * The implementation is based on RFC 6762 and RFC 6763. + * + * The latest source code is always available at + * + * https://github.com/mjansson/mdns + * + * This library is put in the public domain; you can redistribute it and/or modify it without any + * restrictions. + * + */ + +#pragma once + +#include +#include +#include +#include + +#include +#ifdef _WIN32 +#include +#include +#define strncasecmp _strnicmp +#else +#include +#include +#include +#endif + +#ifdef __cplusplus +extern "C" { +#endif + +#define MDNS_INVALID_POS ((size_t)-1) + +#define MDNS_STRING_CONST(s) (s), (sizeof((s)) - 1) +#define MDNS_STRING_ARGS(s) s.str, s.length +#define MDNS_STRING_FORMAT(s) (int)((s).length), s.str + +#define MDNS_POINTER_OFFSET(p, ofs) ((void*)((char*)(p) + (ptrdiff_t)(ofs))) +#define MDNS_POINTER_OFFSET_CONST(p, ofs) ((const void*)((const char*)(p) + (ptrdiff_t)(ofs))) +#define MDNS_POINTER_DIFF(a, b) ((size_t)((const char*)(a) - (const char*)(b))) + +#define MDNS_PORT 5353 +#define MDNS_UNICAST_RESPONSE 0x8000U +#define MDNS_CACHE_FLUSH 0x8000U +#define MDNS_MAX_SUBSTRINGS 64 + +enum mdns_record_type { + MDNS_RECORDTYPE_IGNORE = 0, + // Address + MDNS_RECORDTYPE_A = 1, + // Domain Name pointer + MDNS_RECORDTYPE_PTR = 12, + // Arbitrary text string + MDNS_RECORDTYPE_TXT = 16, + // IP6 Address [Thomson] + MDNS_RECORDTYPE_AAAA = 28, + // Server Selection [RFC2782] + MDNS_RECORDTYPE_SRV = 33, + // Any available records + MDNS_RECORDTYPE_ANY = 255 +}; + +enum mdns_entry_type { + MDNS_ENTRYTYPE_QUESTION = 0, + MDNS_ENTRYTYPE_ANSWER = 1, + MDNS_ENTRYTYPE_AUTHORITY = 2, + MDNS_ENTRYTYPE_ADDITIONAL = 3 +}; + +enum mdns_class { MDNS_CLASS_IN = 1, MDNS_CLASS_ANY = 255 }; + +typedef enum mdns_record_type mdns_record_type_t; +typedef enum mdns_entry_type mdns_entry_type_t; +typedef enum mdns_class mdns_class_t; + +typedef int (*mdns_record_callback_fn)(int sock, const struct sockaddr* from, size_t addrlen, + mdns_entry_type_t entry, uint16_t query_id, uint16_t rtype, + uint16_t rclass, uint32_t ttl, const void* data, size_t size, + size_t name_offset, size_t name_length, size_t record_offset, + size_t record_length, void* user_data); + +typedef struct mdns_string_t mdns_string_t; +typedef struct mdns_string_pair_t mdns_string_pair_t; +typedef struct mdns_string_table_item_t mdns_string_table_item_t; +typedef struct mdns_string_table_t mdns_string_table_t; +typedef struct mdns_record_t mdns_record_t; +typedef struct mdns_record_srv_t mdns_record_srv_t; +typedef struct mdns_record_ptr_t mdns_record_ptr_t; +typedef struct mdns_record_a_t mdns_record_a_t; +typedef struct mdns_record_aaaa_t mdns_record_aaaa_t; +typedef struct mdns_record_txt_t mdns_record_txt_t; +typedef struct mdns_query_t mdns_query_t; + +#ifdef _WIN32 +typedef int mdns_size_t; +typedef int mdns_ssize_t; +#else +typedef size_t mdns_size_t; +typedef ssize_t mdns_ssize_t; +#endif + +struct mdns_string_t { + const char* str; + size_t length; +}; + +struct mdns_string_pair_t { + size_t offset; + size_t length; + int ref; +}; + +struct mdns_string_table_t { + size_t offset[16]; + size_t count; + size_t next; +}; + +struct mdns_record_srv_t { + uint16_t priority; + uint16_t weight; + uint16_t port; + mdns_string_t name; +}; + +struct mdns_record_ptr_t { + mdns_string_t name; +}; + +struct mdns_record_a_t { + struct sockaddr_in addr; +}; + +struct mdns_record_aaaa_t { + struct sockaddr_in6 addr; +}; + +struct mdns_record_txt_t { + mdns_string_t key; + mdns_string_t value; +}; + +struct mdns_record_t { + mdns_string_t name; + mdns_record_type_t type; + union mdns_record_data { + mdns_record_ptr_t ptr; + mdns_record_srv_t srv; + mdns_record_a_t a; + mdns_record_aaaa_t aaaa; + mdns_record_txt_t txt; + } data; + uint16_t rclass; + uint32_t ttl; +}; + +struct mdns_header_t { + uint16_t query_id; + uint16_t flags; + uint16_t questions; + uint16_t answer_rrs; + uint16_t authority_rrs; + uint16_t additional_rrs; +}; + +struct mdns_query_t { + mdns_record_type_t type; + const char* name; + size_t length; +}; + +// mDNS/DNS-SD public API + +//! Open and setup a IPv4 socket for mDNS/DNS-SD. To bind the socket to a specific interface, pass +//! in the appropriate socket address in saddr, otherwise pass a null pointer for INADDR_ANY. To +//! send one-shot discovery requests and queries pass a null pointer or set 0 as port to assign a +//! random user level ephemeral port. To run discovery service listening for incoming discoveries +//! and queries, you must set MDNS_PORT as port. +static inline int +mdns_socket_open_ipv4(const struct sockaddr_in* saddr); + +//! Setup an already opened IPv4 socket for mDNS/DNS-SD. To bind the socket to a specific interface, +//! pass in the appropriate socket address in saddr, otherwise pass a null pointer for INADDR_ANY. +//! To send one-shot discovery requests and queries pass a null pointer or set 0 as port to assign a +//! random user level ephemeral port. To run discovery service listening for incoming discoveries +//! and queries, you must set MDNS_PORT as port. +static inline int +mdns_socket_setup_ipv4(int sock, const struct sockaddr_in* saddr); + +//! Open and setup a IPv6 socket for mDNS/DNS-SD. To bind the socket to a specific interface, pass +//! in the appropriate socket address in saddr, otherwise pass a null pointer for in6addr_any. To +//! send one-shot discovery requests and queries pass a null pointer or set 0 as port to assign a +//! random user level ephemeral port. To run discovery service listening for incoming discoveries +//! and queries, you must set MDNS_PORT as port. +static inline int +mdns_socket_open_ipv6(const struct sockaddr_in6* saddr); + +//! Setup an already opened IPv6 socket for mDNS/DNS-SD. To bind the socket to a specific interface, +//! pass in the appropriate socket address in saddr, otherwise pass a null pointer for in6addr_any. +//! To send one-shot discovery requests and queries pass a null pointer or set 0 as port to assign a +//! random user level ephemeral port. To run discovery service listening for incoming discoveries +//! and queries, you must set MDNS_PORT as port. +static inline int +mdns_socket_setup_ipv6(int sock, const struct sockaddr_in6* saddr); + +//! Close a socket opened with mdns_socket_open_ipv4 and mdns_socket_open_ipv6. +static inline void +mdns_socket_close(int sock); + +//! Listen for incoming multicast DNS-SD and mDNS query requests. The socket should have been opened +//! on port MDNS_PORT using one of the mdns open or setup socket functions. Buffer must be 32 bit +//! aligned. Parsing is stopped when callback function returns non-zero. Returns the number of +//! queries parsed. +static inline size_t +mdns_socket_listen(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data); + +//! Send a multicast DNS-SD reqeuest on the given socket to discover available services. Returns 0 +//! on success, or <0 if error. +static inline int +mdns_discovery_send(int sock); + +//! Recieve unicast responses to a DNS-SD sent with mdns_discovery_send. Any data will be piped to +//! the given callback for parsing. Buffer must be 32 bit aligned. Parsing is stopped when callback +//! function returns non-zero. Returns the number of responses parsed. +static inline size_t +mdns_discovery_recv(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data); + +//! Send a multicast mDNS query on the given socket for the given service name. The supplied buffer +//! will be used to build the query packet and must be 32 bit aligned. The query ID can be set to +//! non-zero to filter responses, however the RFC states that the query ID SHOULD be set to 0 for +//! multicast queries. The query will request a unicast response if the socket is bound to an +//! ephemeral port, or a multicast response if the socket is bound to mDNS port 5353. Returns the +//! used query ID, or <0 if error. +static inline int +mdns_query_send(int sock, mdns_record_type_t type, const char* name, size_t length, void* buffer, + size_t capacity, uint16_t query_id); + +//! Send a multicast mDNS query on the given socket for the given service names. The supplied buffer +//! will be used to build the query packet and must be 32 bit aligned. The query ID can be set to +//! non-zero to filter responses, however the RFC states that the query ID SHOULD be set to 0 for +//! multicast queries. Each additional service name query consists of a triplet - a record type +//! (mdns_record_type_t), a name string pointer (const char*) and a name length (size_t). The list +//! of variable arguments should be terminated with a record type of 0. The query will request a +//! unicast response if the socket is bound to an ephemeral port, or a multicast response if the +//! socket is bound to mDNS port 5353. Returns the used query ID, or <0 if error. +static inline int +mdns_multiquery_send(int sock, const mdns_query_t* query, size_t count, void* buffer, + size_t capacity, uint16_t query_id); + +//! Receive unicast responses to a mDNS query sent with mdns_[multi]query_send, optionally filtering +//! out any responses not matching the given query ID. Set the query ID to 0 to parse all responses, +//! even if it is not matching the query ID set in a specific query. Any data will be piped to the +//! given callback for parsing. Buffer must be 32 bit aligned. Parsing is stopped when callback +//! function returns non-zero. Returns the number of responses parsed. +static inline size_t +mdns_query_recv(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data, int query_id); + +//! Send a variable unicast mDNS query answer to any question with variable number of records to the +//! given address. Use the top bit of the query class field (MDNS_UNICAST_RESPONSE) in the query +//! recieved to determine if the answer should be sent unicast (bit set) or multicast (bit not set). +//! Buffer must be 32 bit aligned. The record type and name should match the data from the query +//! recieved. Returns 0 if success, or <0 if error. +static inline int +mdns_query_answer_unicast(int sock, const void* address, size_t address_size, void* buffer, + size_t capacity, uint16_t query_id, mdns_record_type_t record_type, + const char* name, size_t name_length, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count); + +//! Send a variable multicast mDNS query answer to any question with variable number of records. Use +//! the top bit of the query class field (MDNS_UNICAST_RESPONSE) in the query recieved to determine +//! if the answer should be sent unicast (bit set) or multicast (bit not set). Buffer must be 32 bit +//! aligned. Returns 0 if success, or <0 if error. +static inline int +mdns_query_answer_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count); + +//! Send a variable multicast mDNS announcement (as an unsolicited answer) with variable number of +//! records.Buffer must be 32 bit aligned. Returns 0 if success, or <0 if error. Use this on service +//! startup to announce your instance to the local network. +static inline int +mdns_announce_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count); + +//! Send a variable multicast mDNS announcement. Use this on service end for removing the resource +//! from the local network. The records must be identical to the according announcement. +static inline int +mdns_goodbye_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count); + +// Parse records functions + +//! Parse a PTR record, returns the name in the record +static inline mdns_string_t +mdns_record_parse_ptr(const void* buffer, size_t size, size_t offset, size_t length, + char* strbuffer, size_t capacity); + +//! Parse a SRV record, returns the priority, weight, port and name in the record +static inline mdns_record_srv_t +mdns_record_parse_srv(const void* buffer, size_t size, size_t offset, size_t length, + char* strbuffer, size_t capacity); + +//! Parse an A record, returns the IPv4 address in the record +static inline struct sockaddr_in* +mdns_record_parse_a(const void* buffer, size_t size, size_t offset, size_t length, + struct sockaddr_in* addr); + +//! Parse an AAAA record, returns the IPv6 address in the record +static inline struct sockaddr_in6* +mdns_record_parse_aaaa(const void* buffer, size_t size, size_t offset, size_t length, + struct sockaddr_in6* addr); + +//! Parse a TXT record, returns the number of key=value records parsed and stores the key-value +//! pairs in the supplied buffer +static inline size_t +mdns_record_parse_txt(const void* buffer, size_t size, size_t offset, size_t length, + mdns_record_txt_t* records, size_t capacity); + +// Internal functions + +static inline mdns_string_t +mdns_string_extract(const void* buffer, size_t size, size_t* offset, char* str, size_t capacity); + +static inline int +mdns_string_skip(const void* buffer, size_t size, size_t* offset); + +static inline size_t +mdns_string_find(const char* str, size_t length, char c, size_t offset); + +//! Compare if two strings are equal. If the strings are equal it returns >0 and the offset variables are +//! updated to the end of the corresponding strings. If the strings are not equal it returns 0 and +//! the offset variables are NOT updated. +static inline int +mdns_string_equal(const void* buffer_lhs, size_t size_lhs, size_t* ofs_lhs, const void* buffer_rhs, + size_t size_rhs, size_t* ofs_rhs); + +static inline void* +mdns_string_make(void* buffer, size_t capacity, void* data, const char* name, size_t length, + mdns_string_table_t* string_table); + +static inline size_t +mdns_string_table_find(mdns_string_table_t* string_table, const void* buffer, size_t capacity, + const char* str, size_t first_length, size_t total_length); + +// Implementations + +static inline uint16_t +mdns_ntohs(const void* data) { + uint16_t aligned; + memcpy(&aligned, data, sizeof(uint16_t)); + return ntohs(aligned); +} + +static inline uint32_t +mdns_ntohl(const void* data) { + uint32_t aligned; + memcpy(&aligned, data, sizeof(uint32_t)); + return ntohl(aligned); +} + +static inline void* +mdns_htons(void* data, uint16_t val) { + val = htons(val); + memcpy(data, &val, sizeof(uint16_t)); + return MDNS_POINTER_OFFSET(data, sizeof(uint16_t)); +} + +static inline void* +mdns_htonl(void* data, uint32_t val) { + val = htonl(val); + memcpy(data, &val, sizeof(uint32_t)); + return MDNS_POINTER_OFFSET(data, sizeof(uint32_t)); +} + +static inline int +mdns_socket_open_ipv4(const struct sockaddr_in* saddr) { + int sock = (int)socket(AF_INET, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) + return -1; + if (mdns_socket_setup_ipv4(sock, saddr)) { + mdns_socket_close(sock); + return -1; + } + return sock; +} + +static inline int +mdns_socket_setup_ipv4(int sock, const struct sockaddr_in* saddr) { + unsigned char ttl = 1; + unsigned char loopback = 1; + unsigned int reuseaddr = 1; + struct ip_mreq req; + + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuseaddr, sizeof(reuseaddr)); +#ifdef SO_REUSEPORT + setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, (const char*)&reuseaddr, sizeof(reuseaddr)); +#endif + setsockopt(sock, IPPROTO_IP, IP_MULTICAST_TTL, (const char*)&ttl, sizeof(ttl)); + setsockopt(sock, IPPROTO_IP, IP_MULTICAST_LOOP, (const char*)&loopback, sizeof(loopback)); + + memset(&req, 0, sizeof(req)); + req.imr_multiaddr.s_addr = htonl((((uint32_t)224U) << 24U) | ((uint32_t)251U)); + if (saddr) + req.imr_interface = saddr->sin_addr; + if (setsockopt(sock, IPPROTO_IP, IP_ADD_MEMBERSHIP, (char*)&req, sizeof(req))) + return -1; + + struct sockaddr_in sock_addr; + if (!saddr) { + memset(&sock_addr, 0, sizeof(struct sockaddr_in)); + sock_addr.sin_family = AF_INET; + sock_addr.sin_addr.s_addr = INADDR_ANY; +#ifdef __APPLE__ + sock_addr.sin_len = sizeof(struct sockaddr_in); +#endif + } else { + memcpy(&sock_addr, saddr, sizeof(struct sockaddr_in)); + setsockopt(sock, IPPROTO_IP, IP_MULTICAST_IF, (const char*)&sock_addr.sin_addr, + sizeof(sock_addr.sin_addr)); +#ifndef _WIN32 + sock_addr.sin_addr.s_addr = INADDR_ANY; +#endif + } + + if (bind(sock, (struct sockaddr*)&sock_addr, sizeof(struct sockaddr_in))) + return -1; + +#ifdef _WIN32 + unsigned long param = 1; + ioctlsocket(sock, FIONBIO, ¶m); +#else + const int flags = fcntl(sock, F_GETFL, 0); + fcntl(sock, F_SETFL, flags | O_NONBLOCK); +#endif + + return 0; +} + +static inline int +mdns_socket_open_ipv6(const struct sockaddr_in6* saddr) { + int sock = (int)socket(AF_INET6, SOCK_DGRAM, IPPROTO_UDP); + if (sock < 0) + return -1; + if (mdns_socket_setup_ipv6(sock, saddr)) { + mdns_socket_close(sock); + return -1; + } + return sock; +} + +static inline int +mdns_socket_setup_ipv6(int sock, const struct sockaddr_in6* saddr) { + int hops = 1; + unsigned int loopback = 1; + unsigned int reuseaddr = 1; + struct ipv6_mreq req; + + setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuseaddr, sizeof(reuseaddr)); +#ifdef SO_REUSEPORT + setsockopt(sock, SOL_SOCKET, SO_REUSEPORT, (const char*)&reuseaddr, sizeof(reuseaddr)); +#endif + setsockopt(sock, IPPROTO_IPV6, IPV6_MULTICAST_HOPS, (const char*)&hops, sizeof(hops)); + setsockopt(sock, IPPROTO_IPV6, IPV6_MULTICAST_LOOP, (const char*)&loopback, sizeof(loopback)); + + memset(&req, 0, sizeof(req)); + req.ipv6mr_multiaddr.s6_addr[0] = 0xFF; + req.ipv6mr_multiaddr.s6_addr[1] = 0x02; + req.ipv6mr_multiaddr.s6_addr[15] = 0xFB; + if (setsockopt(sock, IPPROTO_IPV6, IPV6_JOIN_GROUP, (char*)&req, sizeof(req))) + return -1; + + struct sockaddr_in6 sock_addr; + if (!saddr) { + memset(&sock_addr, 0, sizeof(struct sockaddr_in6)); + sock_addr.sin6_family = AF_INET6; + sock_addr.sin6_addr = in6addr_any; +#ifdef __APPLE__ + sock_addr.sin6_len = sizeof(struct sockaddr_in6); +#endif + } else { + memcpy(&sock_addr, saddr, sizeof(struct sockaddr_in6)); + unsigned int ifindex = 0; + setsockopt(sock, IPPROTO_IPV6, IPV6_MULTICAST_IF, (const char*)&ifindex, sizeof(ifindex)); +#ifndef _WIN32 + sock_addr.sin6_addr = in6addr_any; +#endif + } + + if (bind(sock, (struct sockaddr*)&sock_addr, sizeof(struct sockaddr_in6))) + return -1; + +#ifdef _WIN32 + unsigned long param = 1; + ioctlsocket(sock, FIONBIO, ¶m); +#else + const int flags = fcntl(sock, F_GETFL, 0); + fcntl(sock, F_SETFL, flags | O_NONBLOCK); +#endif + + return 0; +} + +static inline void +mdns_socket_close(int sock) { +#ifdef _WIN32 + closesocket(sock); +#else + close(sock); +#endif +} + +static inline int +mdns_is_string_ref(uint8_t val) { + return (0xC0 == (val & 0xC0)); +} + +static inline mdns_string_pair_t +mdns_get_next_substring(const void* rawdata, size_t size, size_t offset) { + const uint8_t* buffer = (const uint8_t*)rawdata; + mdns_string_pair_t pair = {MDNS_INVALID_POS, 0, 0}; + if (offset >= size) + return pair; + if (!buffer[offset]) { + pair.offset = offset; + return pair; + } + int recursion = 0; + while (mdns_is_string_ref(buffer[offset])) { + if (size < offset + 2) + return pair; + + offset = mdns_ntohs(MDNS_POINTER_OFFSET(buffer, offset)) & 0x3fff; + if (offset >= size) + return pair; + + pair.ref = 1; + if (++recursion > 16) + return pair; + } + + size_t length = (size_t)buffer[offset++]; + if (size < offset + length) + return pair; + + pair.offset = offset; + pair.length = length; + + return pair; +} + +static inline int +mdns_string_skip(const void* buffer, size_t size, size_t* offset) { + size_t cur = *offset; + mdns_string_pair_t substr; + unsigned int counter = 0; + do { + substr = mdns_get_next_substring(buffer, size, cur); + if ((substr.offset == MDNS_INVALID_POS) || (counter++ > MDNS_MAX_SUBSTRINGS)) + return 0; + if (substr.ref) { + *offset = cur + 2; + return 1; + } + cur = substr.offset + substr.length; + } while (substr.length); + + *offset = cur + 1; + return 1; +} + +static inline int +mdns_string_equal(const void* buffer_lhs, size_t size_lhs, size_t* ofs_lhs, const void* buffer_rhs, + size_t size_rhs, size_t* ofs_rhs) { + size_t lhs_cur = *ofs_lhs; + size_t rhs_cur = *ofs_rhs; + size_t lhs_end = MDNS_INVALID_POS; + size_t rhs_end = MDNS_INVALID_POS; + mdns_string_pair_t lhs_substr; + mdns_string_pair_t rhs_substr; + unsigned int counter = 0; + do { + lhs_substr = mdns_get_next_substring(buffer_lhs, size_lhs, lhs_cur); + rhs_substr = mdns_get_next_substring(buffer_rhs, size_rhs, rhs_cur); + if ((lhs_substr.offset == MDNS_INVALID_POS) || (rhs_substr.offset == MDNS_INVALID_POS) || + (counter++ > MDNS_MAX_SUBSTRINGS)) + return 0; + if (lhs_substr.length != rhs_substr.length) + return 0; + if (strncasecmp((const char*)MDNS_POINTER_OFFSET_CONST(buffer_rhs, rhs_substr.offset), + (const char*)MDNS_POINTER_OFFSET_CONST(buffer_lhs, lhs_substr.offset), + rhs_substr.length)) + return 0; + if (lhs_substr.ref && (lhs_end == MDNS_INVALID_POS)) + lhs_end = lhs_cur + 2; + if (rhs_substr.ref && (rhs_end == MDNS_INVALID_POS)) + rhs_end = rhs_cur + 2; + lhs_cur = lhs_substr.offset + lhs_substr.length; + rhs_cur = rhs_substr.offset + rhs_substr.length; + } while (lhs_substr.length); + + if (lhs_end == MDNS_INVALID_POS) + lhs_end = lhs_cur + 1; + *ofs_lhs = lhs_end; + + if (rhs_end == MDNS_INVALID_POS) + rhs_end = rhs_cur + 1; + *ofs_rhs = rhs_end; + + return 1; +} + +static inline mdns_string_t +mdns_string_extract(const void* buffer, size_t size, size_t* offset, char* str, size_t capacity) { + size_t cur = *offset; + size_t end = MDNS_INVALID_POS; + mdns_string_pair_t substr; + mdns_string_t result; + result.str = str; + result.length = 0; + char* dst = str; + unsigned int counter = 0; + size_t remain = capacity; + do { + substr = mdns_get_next_substring(buffer, size, cur); + if ((substr.offset == MDNS_INVALID_POS) || (counter++ > MDNS_MAX_SUBSTRINGS)) + return result; + if (substr.ref && (end == MDNS_INVALID_POS)) + end = cur + 2; + if (substr.length) { + size_t to_copy = (substr.length < remain) ? substr.length : remain; + memcpy(dst, (const char*)buffer + substr.offset, to_copy); + dst += to_copy; + remain -= to_copy; + if (remain) { + *dst++ = '.'; + --remain; + } + } + cur = substr.offset + substr.length; + } while (substr.length); + + if (end == MDNS_INVALID_POS) + end = cur + 1; + *offset = end; + + result.length = capacity - remain; + return result; +} + +static inline size_t +mdns_string_table_find(mdns_string_table_t* string_table, const void* buffer, size_t capacity, + const char* str, size_t first_length, size_t total_length) { + if (!string_table) + return MDNS_INVALID_POS; + + for (size_t istr = 0; istr < string_table->count; ++istr) { + if (string_table->offset[istr] >= capacity) + continue; + size_t offset = 0; + mdns_string_pair_t sub_string = + mdns_get_next_substring(buffer, capacity, string_table->offset[istr]); + if (!sub_string.length || (sub_string.length != first_length)) + continue; + if (memcmp(str, MDNS_POINTER_OFFSET(buffer, sub_string.offset), sub_string.length)) + continue; + + // Initial substring matches, now match all remaining substrings + offset += first_length + 1; + while (offset < total_length) { + size_t dot_pos = mdns_string_find(str, total_length, '.', offset); + if (dot_pos == MDNS_INVALID_POS) + dot_pos = total_length; + size_t current_length = dot_pos - offset; + + sub_string = + mdns_get_next_substring(buffer, capacity, sub_string.offset + sub_string.length); + if (!sub_string.length || (sub_string.length != current_length)) + break; + if (memcmp(str + offset, MDNS_POINTER_OFFSET(buffer, sub_string.offset), + sub_string.length)) + break; + + offset = dot_pos + 1; + } + + // Return reference offset if entire string matches + if (offset >= total_length) + return string_table->offset[istr]; + } + + return MDNS_INVALID_POS; +} + +static inline void +mdns_string_table_add(mdns_string_table_t* string_table, size_t offset) { + if (!string_table) + return; + + string_table->offset[string_table->next] = offset; + + size_t table_capacity = sizeof(string_table->offset) / sizeof(string_table->offset[0]); + if (++string_table->count > table_capacity) + string_table->count = table_capacity; + if (++string_table->next >= table_capacity) + string_table->next = 0; +} + +static inline size_t +mdns_string_find(const char* str, size_t length, char c, size_t offset) { + const void* found; + if (offset >= length) + return MDNS_INVALID_POS; + found = memchr(str + offset, c, length - offset); + if (found) + return (size_t)MDNS_POINTER_DIFF(found, str); + return MDNS_INVALID_POS; +} + +static inline void* +mdns_string_make_ref(void* data, size_t capacity, size_t ref_offset) { + if (capacity < 2) + return 0; + return mdns_htons(data, 0xC000 | (uint16_t)ref_offset); +} + +static inline void* +mdns_string_make(void* buffer, size_t capacity, void* data, const char* name, size_t length, + mdns_string_table_t* string_table) { + size_t last_pos = 0; + size_t remain = capacity - MDNS_POINTER_DIFF(data, buffer); + if (name[length - 1] == '.') + --length; + while (last_pos < length) { + size_t pos = mdns_string_find(name, length, '.', last_pos); + size_t sub_length = ((pos != MDNS_INVALID_POS) ? pos : length) - last_pos; + size_t total_length = length - last_pos; + + size_t ref_offset = + mdns_string_table_find(string_table, buffer, capacity, + (char*)MDNS_POINTER_OFFSET(name, last_pos), sub_length, + total_length); + if (ref_offset != MDNS_INVALID_POS) + return mdns_string_make_ref(data, remain, ref_offset); + + if (remain <= (sub_length + 1)) + return 0; + + *(unsigned char*)data = (unsigned char)sub_length; + memcpy(MDNS_POINTER_OFFSET(data, 1), name + last_pos, sub_length); + mdns_string_table_add(string_table, MDNS_POINTER_DIFF(data, buffer)); + + data = MDNS_POINTER_OFFSET(data, sub_length + 1); + last_pos = ((pos != MDNS_INVALID_POS) ? pos + 1 : length); + remain = capacity - MDNS_POINTER_DIFF(data, buffer); + } + + if (!remain) + return 0; + + *(unsigned char*)data = 0; + return MDNS_POINTER_OFFSET(data, 1); +} + +static inline size_t +mdns_records_parse(int sock, const struct sockaddr* from, size_t addrlen, const void* buffer, + size_t size, size_t* offset, mdns_entry_type_t type, uint16_t query_id, + size_t records, mdns_record_callback_fn callback, void* user_data) { + size_t parsed = 0; + for (size_t i = 0; i < records; ++i) { + size_t name_offset = *offset; + mdns_string_skip(buffer, size, offset); + if (((*offset) + 10) > size) + return parsed; + size_t name_length = (*offset) - name_offset; + const uint16_t* data = (const uint16_t*)MDNS_POINTER_OFFSET(buffer, *offset); + + uint16_t rtype = mdns_ntohs(data++); + uint16_t rclass = mdns_ntohs(data++); + uint32_t ttl = mdns_ntohl(data); + data += 2; + uint16_t length = mdns_ntohs(data++); + + *offset += 10; + + if (length <= (size - (*offset))) { + ++parsed; + if (callback && + callback(sock, from, addrlen, type, query_id, rtype, rclass, ttl, buffer, size, + name_offset, name_length, *offset, length, user_data)) + break; + } + + *offset += length; + } + return parsed; +} + +static inline int +mdns_unicast_send(int sock, const void* address, size_t address_size, const void* buffer, + size_t size) { + if (sendto(sock, (const char*)buffer, (mdns_size_t)size, 0, (const struct sockaddr*)address, + (socklen_t)address_size) < 0) + return -1; + return 0; +} + +static inline int +mdns_multicast_send(int sock, const void* buffer, size_t size) { + struct sockaddr_storage addr_storage; + struct sockaddr_in addr; + struct sockaddr_in6 addr6; + struct sockaddr* saddr = (struct sockaddr*)&addr_storage; + socklen_t saddrlen = sizeof(struct sockaddr_storage); + if (getsockname(sock, saddr, &saddrlen)) + return -1; + if (saddr->sa_family == AF_INET6) { + memset(&addr6, 0, sizeof(addr6)); + addr6.sin6_family = AF_INET6; +#ifdef __APPLE__ + addr6.sin6_len = sizeof(addr6); +#endif + addr6.sin6_addr.s6_addr[0] = 0xFF; + addr6.sin6_addr.s6_addr[1] = 0x02; + addr6.sin6_addr.s6_addr[15] = 0xFB; + addr6.sin6_port = htons((unsigned short)MDNS_PORT); + saddr = (struct sockaddr*)&addr6; + saddrlen = sizeof(addr6); + } else { + memset(&addr, 0, sizeof(addr)); + addr.sin_family = AF_INET; +#ifdef __APPLE__ + addr.sin_len = sizeof(addr); +#endif + addr.sin_addr.s_addr = htonl((((uint32_t)224U) << 24U) | ((uint32_t)251U)); + addr.sin_port = htons((unsigned short)MDNS_PORT); + saddr = (struct sockaddr*)&addr; + saddrlen = sizeof(addr); + } + + if (sendto(sock, (const char*)buffer, (mdns_size_t)size, 0, saddr, saddrlen) < 0) + return -1; + return 0; +} + +static const uint8_t mdns_services_query[] = { + // Query ID + 0x00, 0x00, + // Flags + 0x00, 0x00, + // 1 question + 0x00, 0x01, + // No answer, authority or additional RRs + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // _services._dns-sd._udp.local. + 0x09, '_', 's', 'e', 'r', 'v', 'i', 'c', 'e', 's', 0x07, '_', 'd', 'n', 's', '-', 's', 'd', + 0x04, '_', 'u', 'd', 'p', 0x05, 'l', 'o', 'c', 'a', 'l', 0x00, + // PTR record + 0x00, MDNS_RECORDTYPE_PTR, + // QU (unicast response) and class IN + 0x80, MDNS_CLASS_IN}; + +static inline int +mdns_discovery_send(int sock) { + return mdns_multicast_send(sock, mdns_services_query, sizeof(mdns_services_query)); +} + +static inline size_t +mdns_discovery_recv(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data) { + struct sockaddr_in6 addr; + struct sockaddr* saddr = (struct sockaddr*)&addr; + socklen_t addrlen = sizeof(addr); + memset(&addr, 0, sizeof(addr)); +#ifdef __APPLE__ + saddr->sa_len = sizeof(addr); +#endif + mdns_ssize_t ret = recvfrom(sock, (char*)buffer, (mdns_size_t)capacity, 0, saddr, &addrlen); + if (ret <= 0) + return 0; + + size_t data_size = (size_t)ret; + size_t records = 0; + const uint16_t* data = (uint16_t*)buffer; + + uint16_t query_id = mdns_ntohs(data++); + uint16_t flags = mdns_ntohs(data++); + uint16_t questions = mdns_ntohs(data++); + uint16_t answer_rrs = mdns_ntohs(data++); + uint16_t authority_rrs = mdns_ntohs(data++); + uint16_t additional_rrs = mdns_ntohs(data++); + + // According to RFC 6762 the query ID MUST match the sent query ID (which is 0 in our case) + if (query_id || (flags != 0x8400)) + return 0; // Not a reply to our question + + // It seems some implementations do not fill the correct questions field, + // so ignore this check for now and only validate answer string + // if (questions != 1) + // return 0; + + int i; + for (i = 0; i < questions; ++i) { + size_t offset = MDNS_POINTER_DIFF(data, buffer); + size_t verify_offset = 12; + // Verify it's our question, _services._dns-sd._udp.local. + if (!mdns_string_equal(buffer, data_size, &offset, mdns_services_query, + sizeof(mdns_services_query), &verify_offset)) + return 0; + data = (const uint16_t*)MDNS_POINTER_OFFSET(buffer, offset); + + uint16_t rtype = mdns_ntohs(data++); + uint16_t rclass = mdns_ntohs(data++); + + // Make sure we get a reply based on our PTR question for class IN + if ((rtype != MDNS_RECORDTYPE_PTR) || ((rclass & 0x7FFF) != MDNS_CLASS_IN)) + return 0; + } + + for (i = 0; i < answer_rrs; ++i) { + size_t offset = MDNS_POINTER_DIFF(data, buffer); + size_t verify_offset = 12; + // Verify it's an answer to our question, _services._dns-sd._udp.local. + size_t name_offset = offset; + int is_answer = mdns_string_equal(buffer, data_size, &offset, mdns_services_query, + sizeof(mdns_services_query), &verify_offset); + if (!is_answer && !mdns_string_skip(buffer, data_size, &offset)) + break; + size_t name_length = offset - name_offset; + if ((offset + 10) > data_size) + return records; + data = (const uint16_t*)MDNS_POINTER_OFFSET(buffer, offset); + + uint16_t rtype = mdns_ntohs(data++); + uint16_t rclass = mdns_ntohs(data++); + uint32_t ttl = mdns_ntohl(data); + data += 2; + uint16_t length = mdns_ntohs(data++); + if (length > (data_size - offset)) + return 0; + + if (is_answer) { + ++records; + offset = MDNS_POINTER_DIFF(data, buffer); + if (callback && + callback(sock, saddr, addrlen, MDNS_ENTRYTYPE_ANSWER, query_id, rtype, rclass, ttl, + buffer, data_size, name_offset, name_length, offset, length, user_data)) + return records; + } + data = (const uint16_t*)MDNS_POINTER_OFFSET_CONST(data, length); + } + + size_t total_records = records; + size_t offset = MDNS_POINTER_DIFF(data, buffer); + records = + mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_AUTHORITY, query_id, authority_rrs, callback, user_data); + total_records += records; + if (records != authority_rrs) + return total_records; + + records = mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_ADDITIONAL, query_id, additional_rrs, callback, + user_data); + total_records += records; + if (records != additional_rrs) + return total_records; + + return total_records; +} + +static inline size_t +mdns_socket_listen(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data) { + struct sockaddr_in6 addr; + struct sockaddr* saddr = (struct sockaddr*)&addr; + socklen_t addrlen = sizeof(addr); + memset(&addr, 0, sizeof(addr)); +#ifdef __APPLE__ + saddr->sa_len = sizeof(addr); +#endif + mdns_ssize_t ret = recvfrom(sock, (char*)buffer, (mdns_size_t)capacity, 0, saddr, &addrlen); + if (ret <= 0) + return 0; + + size_t data_size = (size_t)ret; + const uint16_t* data = (const uint16_t*)buffer; + + uint16_t query_id = mdns_ntohs(data++); + uint16_t flags = mdns_ntohs(data++); + uint16_t questions = mdns_ntohs(data++); + uint16_t answer_rrs = mdns_ntohs(data++); + uint16_t authority_rrs = mdns_ntohs(data++); + uint16_t additional_rrs = mdns_ntohs(data++); + + size_t records; + size_t total_records = 0; + for (int iquestion = 0; iquestion < questions; ++iquestion) { + size_t question_offset = MDNS_POINTER_DIFF(data, buffer); + size_t offset = question_offset; + size_t verify_offset = 12; + int dns_sd = 0; + if (mdns_string_equal(buffer, data_size, &offset, mdns_services_query, + sizeof(mdns_services_query), &verify_offset)) { + dns_sd = 1; + } else if (!mdns_string_skip(buffer, data_size, &offset)) { + break; + } + size_t length = offset - question_offset; + data = (const uint16_t*)MDNS_POINTER_OFFSET_CONST(buffer, offset); + + uint16_t rtype = mdns_ntohs(data++); + uint16_t rclass = mdns_ntohs(data++); + uint16_t class_without_flushbit = rclass & ~MDNS_CACHE_FLUSH; + + // Make sure we get a question of class IN or ANY + if (!((class_without_flushbit == MDNS_CLASS_IN) || + (class_without_flushbit == MDNS_CLASS_ANY))) { + break; + } + + if (dns_sd && flags) + continue; + + ++total_records; + if (callback && callback(sock, saddr, addrlen, MDNS_ENTRYTYPE_QUESTION, query_id, rtype, + rclass, 0, buffer, data_size, question_offset, length, + question_offset, length, user_data)) + return total_records; + } + + size_t offset = MDNS_POINTER_DIFF(data, buffer); + records = mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_ANSWER, query_id, answer_rrs, callback, user_data); + total_records += records; + if (records != answer_rrs) + return total_records; + + records = + mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_AUTHORITY, query_id, authority_rrs, callback, user_data); + total_records += records; + if (records != authority_rrs) + return total_records; + + records = mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_ADDITIONAL, query_id, additional_rrs, callback, + user_data); + + return total_records; +} + +static inline int +mdns_query_send(int sock, mdns_record_type_t type, const char* name, size_t length, void* buffer, + size_t capacity, uint16_t query_id) { + mdns_query_t query; + query.type = type; + query.name = name; + query.length = length; + return mdns_multiquery_send(sock, &query, 1, buffer, capacity, query_id); +} + +static inline int +mdns_multiquery_send(int sock, const mdns_query_t* query, size_t count, void* buffer, size_t capacity, + uint16_t query_id) { + if (!count || (capacity < (sizeof(struct mdns_header_t) + (6 * count)))) + return -1; + + // Ask for a unicast response since it's a one-shot query + uint16_t rclass = MDNS_CLASS_IN | MDNS_UNICAST_RESPONSE; + + struct sockaddr_storage addr_storage; + struct sockaddr* saddr = (struct sockaddr*)&addr_storage; + socklen_t saddrlen = sizeof(addr_storage); + if (getsockname(sock, saddr, &saddrlen) == 0) { + if ((saddr->sa_family == AF_INET) && + (ntohs(((struct sockaddr_in*)saddr)->sin_port) == MDNS_PORT)) + rclass &= ~MDNS_UNICAST_RESPONSE; + else if ((saddr->sa_family == AF_INET6) && + (ntohs(((struct sockaddr_in6*)saddr)->sin6_port) == MDNS_PORT)) + rclass &= ~MDNS_UNICAST_RESPONSE; + } + + struct mdns_header_t* header = (struct mdns_header_t*)buffer; + // Query ID + header->query_id = htons((unsigned short)query_id); + // Flags + header->flags = 0; + // Questions + header->questions = htons((unsigned short)count); + // No answer, authority or additional RRs + header->answer_rrs = 0; + header->authority_rrs = 0; + header->additional_rrs = 0; + // Fill in questions + void* data = MDNS_POINTER_OFFSET(buffer, sizeof(struct mdns_header_t)); + for (size_t iq = 0; iq < count; ++iq) { + // Name string + data = mdns_string_make(buffer, capacity, data, query[iq].name, query[iq].length, 0); + if (!data) + return -1; + size_t remain = capacity - MDNS_POINTER_DIFF(data, buffer); + if (remain < 4) + return -1; + // Record type + data = mdns_htons(data, query[iq].type); + //! Optional unicast response based on local port, class IN + data = mdns_htons(data, rclass); + } + + size_t tosend = MDNS_POINTER_DIFF(data, buffer); + if (mdns_multicast_send(sock, buffer, (size_t)tosend)) + return -1; + return query_id; +} + +static inline size_t +mdns_query_recv(int sock, void* buffer, size_t capacity, mdns_record_callback_fn callback, + void* user_data, int only_query_id) { + struct sockaddr_in6 addr; + struct sockaddr* saddr = (struct sockaddr*)&addr; + socklen_t addrlen = sizeof(addr); + memset(&addr, 0, sizeof(addr)); +#ifdef __APPLE__ + saddr->sa_len = sizeof(addr); +#endif + mdns_ssize_t ret = recvfrom(sock, (char*)buffer, (mdns_size_t)capacity, 0, saddr, &addrlen); + if (ret <= 0) + return 0; + + size_t data_size = (size_t)ret; + const uint16_t* data = (const uint16_t*)buffer; + + uint16_t query_id = mdns_ntohs(data++); + uint16_t flags = mdns_ntohs(data++); + uint16_t questions = mdns_ntohs(data++); + uint16_t answer_rrs = mdns_ntohs(data++); + uint16_t authority_rrs = mdns_ntohs(data++); + uint16_t additional_rrs = mdns_ntohs(data++); + (void)sizeof(flags); + + if ((only_query_id > 0) && (query_id != only_query_id)) + return 0; // Not a reply to the wanted one-shot query + + // Skip questions part + int i; + for (i = 0; i < questions; ++i) { + size_t offset = MDNS_POINTER_DIFF(data, buffer); + if (!mdns_string_skip(buffer, data_size, &offset)) + return 0; + data = (const uint16_t*)MDNS_POINTER_OFFSET_CONST(buffer, offset); + // Record type and class not used, skip + // uint16_t rtype = mdns_ntohs(data++); + // uint16_t rclass = mdns_ntohs(data++); + data += 2; + } + + size_t records = 0; + size_t total_records = 0; + size_t offset = MDNS_POINTER_DIFF(data, buffer); + records = mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_ANSWER, query_id, answer_rrs, callback, user_data); + total_records += records; + if (records != answer_rrs) + return total_records; + + records = + mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_AUTHORITY, query_id, authority_rrs, callback, user_data); + total_records += records; + if (records != authority_rrs) + return total_records; + + records = mdns_records_parse(sock, saddr, addrlen, buffer, data_size, &offset, + MDNS_ENTRYTYPE_ADDITIONAL, query_id, additional_rrs, callback, + user_data); + total_records += records; + if (records != additional_rrs) + return total_records; + + return total_records; +} + +static inline void* +mdns_answer_add_question_unicast(void* buffer, size_t capacity, void* data, + mdns_record_type_t record_type, const char* name, + size_t name_length, mdns_string_table_t* string_table) { + data = mdns_string_make(buffer, capacity, data, name, name_length, string_table); + if (!data) + return 0; + size_t remain = capacity - MDNS_POINTER_DIFF(data, buffer); + if (remain < 4) + return 0; + + data = mdns_htons(data, record_type); + data = mdns_htons(data, MDNS_UNICAST_RESPONSE | MDNS_CLASS_IN); + + return data; +} + +static inline void* +mdns_answer_add_record_header(void* buffer, size_t capacity, void* data, mdns_record_t record, + mdns_string_table_t* string_table) { + data = mdns_string_make(buffer, capacity, data, record.name.str, record.name.length, string_table); + if (!data) + return 0; + size_t remain = capacity - MDNS_POINTER_DIFF(data, buffer); + if (remain < 10) + return 0; + + data = mdns_htons(data, record.type); + data = mdns_htons(data, record.rclass); + data = mdns_htonl(data, record.ttl); + data = mdns_htons(data, 0); // Length, to be filled later + return data; +} + +static inline void* +mdns_answer_add_record(void* buffer, size_t capacity, void* data, mdns_record_t record, + mdns_string_table_t* string_table) { + // TXT records will be coalesced into one record later + if (!data || (record.type == MDNS_RECORDTYPE_TXT)) + return data; + + data = mdns_answer_add_record_header(buffer, capacity, data, record, string_table); + if (!data) + return 0; + + // Pointer to length of record to be filled at end + void* record_length = MDNS_POINTER_OFFSET(data, -2); + void* record_data = data; + + size_t remain = capacity - MDNS_POINTER_DIFF(data, buffer); + switch (record.type) { + case MDNS_RECORDTYPE_PTR: + data = mdns_string_make(buffer, capacity, data, record.data.ptr.name.str, + record.data.ptr.name.length, string_table); + break; + + case MDNS_RECORDTYPE_SRV: + if (remain <= 6) + return 0; + data = mdns_htons(data, record.data.srv.priority); + data = mdns_htons(data, record.data.srv.weight); + data = mdns_htons(data, record.data.srv.port); + data = mdns_string_make(buffer, capacity, data, record.data.srv.name.str, + record.data.srv.name.length, string_table); + break; + + case MDNS_RECORDTYPE_A: + if (remain < 4) + return 0; + memcpy(data, &record.data.a.addr.sin_addr.s_addr, 4); + data = MDNS_POINTER_OFFSET(data, 4); + break; + + case MDNS_RECORDTYPE_AAAA: + if (remain < 16) + return 0; + memcpy(data, &record.data.aaaa.addr.sin6_addr, 16); // ipv6 address + data = MDNS_POINTER_OFFSET(data, 16); + break; + + default: + break; + } + + if (!data) + return 0; + + // Fill record length + mdns_htons(record_length, (uint16_t)MDNS_POINTER_DIFF(data, record_data)); + return data; +} + +static inline void +mdns_record_update_rclass_ttl(mdns_record_t* record, uint16_t rclass, uint32_t ttl) { + if (!record->rclass) + record->rclass = rclass; + if (!record->ttl || !ttl) + record->ttl = ttl; + record->rclass &= (uint16_t)(MDNS_CLASS_IN | MDNS_CACHE_FLUSH); + // Never flush PTR record + if (record->type == MDNS_RECORDTYPE_PTR) + record->rclass &= ~(uint16_t)MDNS_CACHE_FLUSH; +} + +static inline void* +mdns_answer_add_txt_record(void* buffer, size_t capacity, void* data, const mdns_record_t* records, + size_t record_count, uint16_t rclass, uint32_t ttl, + mdns_string_table_t* string_table) { + // Pointer to length of record to be filled at end + void* record_length = 0; + void* record_data = 0; + + size_t remain = 0; + for (size_t irec = 0; data && (irec < record_count); ++irec) { + if (records[irec].type != MDNS_RECORDTYPE_TXT) + continue; + + mdns_record_t record = records[irec]; + mdns_record_update_rclass_ttl(&record, rclass, ttl); + if (!record_data) { + data = mdns_answer_add_record_header(buffer, capacity, data, record, string_table); + if (!data) + return data; + record_length = MDNS_POINTER_OFFSET(data, -2); + record_data = data; + } + + // TXT strings are unlikely to be shared, just make then raw. Also need one byte for + // termination, thus the <= check + size_t string_length = record.data.txt.key.length + record.data.txt.value.length + 1; + if (!data) + return 0; + remain = capacity - MDNS_POINTER_DIFF(data, buffer); + if ((remain <= string_length) || (string_length > 0x3FFF)) + return 0; + + unsigned char* strdata = (unsigned char*)data; + *strdata++ = (unsigned char)string_length; + memcpy(strdata, record.data.txt.key.str, record.data.txt.key.length); + strdata += record.data.txt.key.length; + *strdata++ = '='; + memcpy(strdata, record.data.txt.value.str, record.data.txt.value.length); + strdata += record.data.txt.value.length; + + data = strdata; + } + + // Fill record length + if (record_data) + mdns_htons(record_length, (uint16_t)MDNS_POINTER_DIFF(data, record_data)); + + return data; +} + +static inline uint16_t +mdns_answer_get_record_count(const mdns_record_t* records, size_t record_count) { + // TXT records will be coalesced into one record + uint16_t total_count = 0; + uint16_t txt_record = 0; + for (size_t irec = 0; irec < record_count; ++irec) { + if (records[irec].type == MDNS_RECORDTYPE_TXT) + txt_record = 1; + else + ++total_count; + } + return total_count + txt_record; +} + +static inline int +mdns_query_answer_unicast(int sock, const void* address, size_t address_size, void* buffer, + size_t capacity, uint16_t query_id, mdns_record_type_t record_type, + const char* name, size_t name_length, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count) { + if (capacity < (sizeof(struct mdns_header_t) + 32 + 4)) + return -1; + + // According to RFC 6762: + // The cache-flush bit MUST NOT be set in any resource records in a response message + // sent in legacy unicast responses to UDP ports other than 5353. + uint16_t rclass = MDNS_CLASS_IN; + uint32_t ttl = 10; + + // Basic answer structure + struct mdns_header_t* header = (struct mdns_header_t*)buffer; + header->query_id = htons(query_id); + header->flags = htons(0x8400); + header->questions = htons(1); + header->answer_rrs = htons(1); + header->authority_rrs = htons(mdns_answer_get_record_count(authority, authority_count)); + header->additional_rrs = htons(mdns_answer_get_record_count(additional, additional_count)); + + mdns_string_table_t string_table = {{0}, 0, 0}; + void* data = MDNS_POINTER_OFFSET(buffer, sizeof(struct mdns_header_t)); + + // Fill in question + data = mdns_answer_add_question_unicast(buffer, capacity, data, record_type, name, name_length, + &string_table); + + // Fill in answer + answer.rclass = rclass; + answer.ttl = ttl; + data = mdns_answer_add_record(buffer, capacity, data, answer, &string_table); + + // Fill in authority records + for (size_t irec = 0; data && (irec < authority_count); ++irec) { + mdns_record_t record = authority[irec]; + record.rclass = rclass; + if (!record.ttl) + record.ttl = ttl; + data = mdns_answer_add_record(buffer, capacity, data, record, &string_table); + } + data = mdns_answer_add_txt_record(buffer, capacity, data, authority, authority_count, + rclass, ttl, &string_table); + + // Fill in additional records + for (size_t irec = 0; data && (irec < additional_count); ++irec) { + mdns_record_t record = additional[irec]; + record.rclass = rclass; + if (!record.ttl) + record.ttl = ttl; + data = mdns_answer_add_record(buffer, capacity, data, record, &string_table); + } + data = mdns_answer_add_txt_record(buffer, capacity, data, additional, additional_count, + rclass, ttl, &string_table); + if (!data) + return -1; + + size_t tosend = MDNS_POINTER_DIFF(data, buffer); + return mdns_unicast_send(sock, address, address_size, buffer, tosend); +} + +static inline int +mdns_answer_multicast_rclass_ttl(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count, + uint16_t rclass, uint32_t ttl) { + if (capacity < (sizeof(struct mdns_header_t) + 32 + 4)) + return -1; + + // Basic answer structure + struct mdns_header_t* header = (struct mdns_header_t*)buffer; + header->query_id = 0; + header->flags = htons(0x8400); + header->questions = 0; + header->answer_rrs = htons(1); + header->authority_rrs = htons(mdns_answer_get_record_count(authority, authority_count)); + header->additional_rrs = htons(mdns_answer_get_record_count(additional, additional_count)); + + mdns_string_table_t string_table = {{0}, 0, 0}; + void* data = MDNS_POINTER_OFFSET(buffer, sizeof(struct mdns_header_t)); + + // Fill in answer + mdns_record_t record = answer; + mdns_record_update_rclass_ttl(&record, rclass, ttl); + data = mdns_answer_add_record(buffer, capacity, data, record, &string_table); + + // Fill in authority records + for (size_t irec = 0; data && (irec < authority_count); ++irec) { + record = authority[irec]; + mdns_record_update_rclass_ttl(&record, rclass, ttl); + data = mdns_answer_add_record(buffer, capacity, data, record, &string_table); + } + data = mdns_answer_add_txt_record(buffer, capacity, data, authority, authority_count, + rclass, ttl, &string_table); + + // Fill in additional records + for (size_t irec = 0; data && (irec < additional_count); ++irec) { + record = additional[irec]; + mdns_record_update_rclass_ttl(&record, rclass, ttl); + data = mdns_answer_add_record(buffer, capacity, data, record, &string_table); + } + data = mdns_answer_add_txt_record(buffer, capacity, data, additional, additional_count, + rclass, ttl, &string_table); + if (!data) + return -1; + + size_t tosend = MDNS_POINTER_DIFF(data, buffer); + return mdns_multicast_send(sock, buffer, tosend); +} + +static inline int +mdns_query_answer_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count) { + return mdns_answer_multicast_rclass_ttl(sock, buffer, capacity, answer, authority, + authority_count, additional, additional_count, + MDNS_CLASS_IN, 60); +} + +static inline int +mdns_announce_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count) { + return mdns_answer_multicast_rclass_ttl(sock, buffer, capacity, answer, authority, + authority_count, additional, additional_count, + MDNS_CLASS_IN | MDNS_CACHE_FLUSH, 60); +} + +static inline int +mdns_goodbye_multicast(int sock, void* buffer, size_t capacity, mdns_record_t answer, + const mdns_record_t* authority, size_t authority_count, + const mdns_record_t* additional, size_t additional_count) { + // Goodbye should have ttl of 0 + return mdns_answer_multicast_rclass_ttl(sock, buffer, capacity, answer, authority, + authority_count, additional, additional_count, + MDNS_CLASS_IN, 0); +} + +static inline mdns_string_t +mdns_record_parse_ptr(const void* buffer, size_t size, size_t offset, size_t length, + char* strbuffer, size_t capacity) { + // PTR record is just a string + if ((size >= offset + length) && (length >= 2)) + return mdns_string_extract(buffer, size, &offset, strbuffer, capacity); + mdns_string_t empty = {0, 0}; + return empty; +} + +static inline mdns_record_srv_t +mdns_record_parse_srv(const void* buffer, size_t size, size_t offset, size_t length, + char* strbuffer, size_t capacity) { + mdns_record_srv_t srv; + memset(&srv, 0, sizeof(mdns_record_srv_t)); + // Read the service priority, weight, port number and the discovery name + // SRV record format (http://www.ietf.org/rfc/rfc2782.txt): + // 2 bytes network-order unsigned priority + // 2 bytes network-order unsigned weight + // 2 bytes network-order unsigned port + // string: discovery (domain) name, minimum 2 bytes when compressed + if ((size >= offset + length) && (length >= 8)) { + const uint16_t* recorddata = (const uint16_t*)MDNS_POINTER_OFFSET_CONST(buffer, offset); + srv.priority = mdns_ntohs(recorddata++); + srv.weight = mdns_ntohs(recorddata++); + srv.port = mdns_ntohs(recorddata++); + offset += 6; + srv.name = mdns_string_extract(buffer, size, &offset, strbuffer, capacity); + } + return srv; +} + +static inline struct sockaddr_in* +mdns_record_parse_a(const void* buffer, size_t size, size_t offset, size_t length, + struct sockaddr_in* addr) { + memset(addr, 0, sizeof(struct sockaddr_in)); + addr->sin_family = AF_INET; +#ifdef __APPLE__ + addr->sin_len = sizeof(struct sockaddr_in); +#endif + if ((size >= offset + length) && (length == 4)) + memcpy(&addr->sin_addr.s_addr, MDNS_POINTER_OFFSET(buffer, offset), 4); + return addr; +} + +static inline struct sockaddr_in6* +mdns_record_parse_aaaa(const void* buffer, size_t size, size_t offset, size_t length, + struct sockaddr_in6* addr) { + memset(addr, 0, sizeof(struct sockaddr_in6)); + addr->sin6_family = AF_INET6; +#ifdef __APPLE__ + addr->sin6_len = sizeof(struct sockaddr_in6); +#endif + if ((size >= offset + length) && (length == 16)) + memcpy(&addr->sin6_addr, MDNS_POINTER_OFFSET(buffer, offset), 16); + return addr; +} + +static inline size_t +mdns_record_parse_txt(const void* buffer, size_t size, size_t offset, size_t length, + mdns_record_txt_t* records, size_t capacity) { + size_t parsed = 0; + const char* strdata; + size_t end = offset + length; + + if (size < end) + end = size; + + while ((offset < end) && (parsed < capacity)) { + strdata = (const char*)MDNS_POINTER_OFFSET(buffer, offset); + size_t sublength = *(const unsigned char*)strdata; + + if (sublength >= (end - offset)) + break; + + ++strdata; + offset += sublength + 1; + + size_t separator = sublength; + for (size_t c = 0; c < sublength; ++c) { + // DNS-SD TXT record keys MUST be printable US-ASCII, [0x20, 0x7E] + if ((strdata[c] < 0x20) || (strdata[c] > 0x7E)) { + separator = 0; + break; + } + if (strdata[c] == '=') { + separator = c; + break; + } + } + + if (!separator) + continue; + + if (separator < sublength) { + records[parsed].key.str = strdata; + records[parsed].key.length = separator; + records[parsed].value.str = strdata + separator + 1; + records[parsed].value.length = sublength - (separator + 1); + } else { + records[parsed].key.str = strdata; + records[parsed].key.length = sublength; + records[parsed].value.str = 0; + records[parsed].value.length = 0; + } + + ++parsed; + } + + return parsed; +} + +#ifdef _WIN32 +#undef strncasecmp +#endif + +#ifdef __cplusplus +} +#endif diff --git a/src/ros2_medkit_gateway/test/test_aggregation_manager.cpp b/src/ros2_medkit_gateway/test/test_aggregation_manager.cpp new file mode 100644 index 000000000..2379f886f --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_aggregation_manager.cpp @@ -0,0 +1,1407 @@ +// 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 + +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" + +using namespace ros2_medkit_gateway; + +// Use a port that nothing listens on for connection-refused tests +constexpr int DEAD_PORT = 59999; +static const std::string DEAD_URL = "http://127.0.0.1:" + std::to_string(DEAD_PORT); + +// ============================================================================= +// Helper: build AggregationConfig with unreachable static peers +// ============================================================================= + +static AggregationConfig make_config(size_t num_peers) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; // Short timeout for fast test execution + + for (size_t i = 0; i < num_peers; ++i) { + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(DEAD_PORT + static_cast(i)); + peer.name = "peer_" + std::to_string(i); + config.peers.push_back(peer); + } + + return config; +} + +// ============================================================================= +// Construction and peer count tests +// ============================================================================= + +TEST(AggregationManager, adds_static_peers_from_config) { + auto config = make_config(3); + AggregationManager manager(config); + + EXPECT_EQ(manager.peer_count(), 3u); +} + +TEST(AggregationManager, peer_count_reflects_config) { + auto config_empty = make_config(0); + AggregationManager manager_empty(config_empty); + EXPECT_EQ(manager_empty.peer_count(), 0u); + + auto config_one = make_config(1); + AggregationManager manager_one(config_one); + EXPECT_EQ(manager_one.peer_count(), 1u); + + auto config_five = make_config(5); + AggregationManager manager_five(config_five); + EXPECT_EQ(manager_five.peer_count(), 5u); +} + +// ============================================================================= +// Dynamic peer management tests +// ============================================================================= + +TEST(AggregationManager, add_discovered_peer_increases_count) { + auto config = make_config(1); + AggregationManager manager(config); + + EXPECT_EQ(manager.peer_count(), 1u); + + manager.add_discovered_peer("http://192.168.1.50:8081", "discovered_peer"); + EXPECT_EQ(manager.peer_count(), 2u); +} + +TEST(AggregationManager, add_discovered_peer_is_idempotent) { + auto config = make_config(0); + AggregationManager manager(config); + + manager.add_discovered_peer("http://192.168.1.50:8081", "peer_alpha"); + EXPECT_EQ(manager.peer_count(), 1u); + + // Adding again with same name is a no-op + manager.add_discovered_peer("http://192.168.1.50:8082", "peer_alpha"); + EXPECT_EQ(manager.peer_count(), 1u); +} + +// ============================================================================= +// Peer URL validation tests +// ============================================================================= + +TEST(AggregationManager, rejects_non_http_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + manager.add_discovered_peer("ftp://192.168.1.50:8081", "ftp_peer"); + EXPECT_EQ(manager.peer_count(), 0u); + + manager.add_discovered_peer("file:///etc/passwd", "file_peer"); + EXPECT_EQ(manager.peer_count(), 0u); + + manager.add_discovered_peer("not-a-url", "bad_peer"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_cloud_metadata_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + // AWS metadata endpoint + manager.add_discovered_peer("http://169.254.169.254/latest/meta-data/", "aws_meta"); + EXPECT_EQ(manager.peer_count(), 0u); + + // GCP metadata endpoint + manager.add_discovered_peer("http://metadata.google.internal/computeMetadata/v1/", "gcp_meta"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_loopback_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + // IPv4 loopback + manager.add_discovered_peer("http://127.0.0.1:8081", "loopback_v4"); + EXPECT_EQ(manager.peer_count(), 0u); + + manager.add_discovered_peer("http://127.0.0.99:8081", "loopback_v4_2"); + EXPECT_EQ(manager.peer_count(), 0u); + + // localhost + manager.add_discovered_peer("http://localhost:8081", "loopback_name"); + EXPECT_EQ(manager.peer_count(), 0u); + + // IPv6 loopback + manager.add_discovered_peer("http://[::1]:8081", "loopback_v6"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_link_local_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + // Link-local address (not just the metadata endpoint) + manager.add_discovered_peer("http://169.254.1.1:8081", "link_local"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, accepts_valid_http_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + manager.add_discovered_peer("http://192.168.1.50:8081", "valid_peer"); + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, accepts_valid_https_peer_url) { + auto config = make_config(0); + AggregationManager manager(config); + + // Use IP address (not hostname) to avoid DNS resolution issues in test environments + manager.add_discovered_peer("https://10.0.0.1:8443", "secure_peer"); + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, remove_discovered_peer_decreases_count) { + auto config = make_config(2); + AggregationManager manager(config); + + EXPECT_EQ(manager.peer_count(), 2u); + + manager.remove_discovered_peer("peer_0"); + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, remove_nonexistent_peer_is_noop) { + auto config = make_config(2); + AggregationManager manager(config); + + manager.remove_discovered_peer("no_such_peer"); + EXPECT_EQ(manager.peer_count(), 2u); +} + +// ============================================================================= +// Health monitoring tests +// ============================================================================= + +TEST(AggregationManager, healthy_peer_count_zero_when_all_unreachable) { + auto config = make_config(3); + AggregationManager manager(config); + + // Peers start unhealthy (never checked) + EXPECT_EQ(manager.healthy_peer_count(), 0u); + + // After health check, they should still be unhealthy (unreachable) + manager.check_all_health(); + EXPECT_EQ(manager.healthy_peer_count(), 0u); +} + +// ============================================================================= +// Fan-out tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(AggregationManager, fan_out_returns_partial_when_peers_unreachable) { + auto config = make_config(2); + AggregationManager manager(config); + + // No peers are healthy, so fan-out should return empty merged_items + // with no failed_peers (they are simply not included as targets) + auto result = manager.fan_out_get("/api/v1/components", ""); + + EXPECT_TRUE(result.merged_items.is_array()); + EXPECT_TRUE(result.merged_items.empty()); + // No healthy peers means no targets, so no failures + EXPECT_FALSE(result.is_partial); + EXPECT_TRUE(result.failed_peers.empty()); +} + +TEST(AggregationManager, fan_out_returns_empty_array_with_no_peers) { + auto config = make_config(0); + AggregationManager manager(config); + + auto result = manager.fan_out_get("/api/v1/apps", "Bearer token123"); + + EXPECT_TRUE(result.merged_items.is_array()); + EXPECT_TRUE(result.merged_items.empty()); + EXPECT_FALSE(result.is_partial); + EXPECT_TRUE(result.failed_peers.empty()); +} + +// ============================================================================= +// Forward request tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(AggregationManager, forward_returns_502_for_unknown_peer) { + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/components/abc/data"; + httplib::Response res; + + manager.forward_request("nonexistent_peer", req, res); + + EXPECT_EQ(res.status, 502); + + auto body = nlohmann::json::parse(res.body, nullptr, false); + ASSERT_FALSE(body.is_discarded()); + EXPECT_EQ(body["error_code"], "vendor-error"); + EXPECT_EQ(body["vendor_code"], "x-medkit-peer-unavailable"); + EXPECT_TRUE(body["message"].get().find("nonexistent_peer") != std::string::npos); +} + +TEST(AggregationManager, forward_returns_502_for_unreachable_peer) { + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/components/abc/data"; + httplib::Response res; + + // peer_0 exists but is unreachable + manager.forward_request("peer_0", req, res); + + EXPECT_EQ(res.status, 502); +} + +// ============================================================================= +// Peer status tests +// ============================================================================= + +TEST(AggregationManager, get_peer_status_includes_all_peers) { + auto config = make_config(3); + AggregationManager manager(config); + + auto status = manager.get_peer_status(); + + ASSERT_TRUE(status.is_array()); + ASSERT_EQ(status.size(), 3u); + + for (size_t i = 0; i < 3; ++i) { + EXPECT_EQ(status[i]["name"], "peer_" + std::to_string(i)); + EXPECT_TRUE(status[i].contains("url")); + EXPECT_EQ(status[i]["status"], "offline"); + } +} + +TEST(AggregationManager, get_peer_status_empty_when_no_peers) { + auto config = make_config(0); + AggregationManager manager(config); + + auto status = manager.get_peer_status(); + + ASSERT_TRUE(status.is_array()); + EXPECT_TRUE(status.empty()); +} + +// ============================================================================= +// Routing table tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(AggregationManager, routing_table_update_and_find) { + auto config = make_config(0); + AggregationManager manager(config); + + // Initially empty - looking up any entity returns nullopt + EXPECT_FALSE(manager.find_peer_for_entity("anything").has_value()); + + // Update with entries + std::unordered_map table; + table["remote_component_a"] = "peer_x"; + table["remote_app_b"] = "peer_y"; + manager.update_routing_table(table); + + auto result_a = manager.find_peer_for_entity("remote_component_a"); + ASSERT_TRUE(result_a.has_value()); + EXPECT_EQ(*result_a, "peer_x"); + + auto result_b = manager.find_peer_for_entity("remote_app_b"); + ASSERT_TRUE(result_b.has_value()); + EXPECT_EQ(*result_b, "peer_y"); + + // Unknown entity returns nullopt + EXPECT_FALSE(manager.find_peer_for_entity("unknown_entity").has_value()); +} + +TEST(AggregationManager, routing_table_replaces_on_update) { + auto config = make_config(0); + AggregationManager manager(config); + + std::unordered_map table1; + table1["entity_a"] = "peer_1"; + manager.update_routing_table(table1); + EXPECT_TRUE(manager.find_peer_for_entity("entity_a").has_value()); + + // Replace with different table + std::unordered_map table2; + table2["entity_b"] = "peer_2"; + table2["entity_c"] = "peer_3"; + manager.update_routing_table(table2); + + EXPECT_FALSE(manager.find_peer_for_entity("entity_a").has_value()); // Old entry gone + + auto result_b = manager.find_peer_for_entity("entity_b"); + ASSERT_TRUE(result_b.has_value()); + EXPECT_EQ(*result_b, "peer_2"); + + auto result_c = manager.find_peer_for_entity("entity_c"); + ASSERT_TRUE(result_c.has_value()); + EXPECT_EQ(*result_c, "peer_3"); +} + +// ============================================================================= +// get_peer_url tests +// ============================================================================= + +TEST(AggregationManager, get_peer_url_returns_url_for_known_peer) { + auto config = make_config(2); + AggregationManager manager(config); + + std::string url = manager.get_peer_url("peer_0"); + EXPECT_EQ(url, "http://127.0.0.1:" + std::to_string(DEAD_PORT)); + + std::string url1 = manager.get_peer_url("peer_1"); + EXPECT_EQ(url1, "http://127.0.0.1:" + std::to_string(DEAD_PORT + 1)); +} + +TEST(AggregationManager, get_peer_url_returns_empty_for_unknown_peer) { + auto config = make_config(1); + AggregationManager manager(config); + + std::string url = manager.get_peer_url("no_such_peer"); + EXPECT_TRUE(url.empty()); +} + +// ============================================================================= +// fetch_all_peer_entities tests (with unreachable peers) +// ============================================================================= + +TEST(AggregationManager, fetch_all_peer_entities_returns_empty_when_none_healthy) { + auto config = make_config(2); + AggregationManager manager(config); + + // No peers healthy -> empty result + auto entities = manager.fetch_all_peer_entities(); + + EXPECT_TRUE(entities.areas.empty()); + EXPECT_TRUE(entities.components.empty()); + EXPECT_TRUE(entities.apps.empty()); + EXPECT_TRUE(entities.functions.empty()); +} + +// ============================================================================= +// Prefix stripping tests (forward_request) +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(AggregationManager, forward_strips_peer_prefix_from_path) { + // This tests the path rewriting logic: when a collision-renamed entity + // (e.g., peer_0__camera_driver) is forwarded to the peer, the peer prefix + // must be stripped so the peer receives the original entity ID. + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + // Path contains peer_0__ prefix due to collision renaming + req.path = "/api/v1/apps/peer_0__camera_driver/data"; + httplib::Response res; + + // The peer is unreachable, so we get 502. But the test verifies the path + // rewriting happened. The peer_client receives the stripped path. + // We verify the forward completes without crashing and returns 502 + // (peer unreachable), not 404 (wrong entity ID). + manager.forward_request("peer_0", req, res); + + // The peer is unreachable, so we get 502 (not a routing error like 404) + EXPECT_EQ(res.status, 502); +} + +TEST(AggregationManager, forward_preserves_path_without_prefix) { + // When the entity ID does not contain the peer prefix, the path + // should be forwarded unchanged. + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/apps/camera_driver/data"; + httplib::Response res; + + manager.forward_request("peer_0", req, res); + + // Peer unreachable -> 502 + EXPECT_EQ(res.status, 502); +} + +TEST(AggregationManager, forward_strips_only_matching_peer_prefix) { + // Verify that prefix stripping only removes the correct peer prefix, + // not an arbitrary occurrence of the separator. + auto config = make_config(2); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + // Entity has peer_1__ prefix, forwarded to peer_1 + req.path = "/api/v1/components/peer_1__lidar_sensor/data"; + httplib::Response res; + + manager.forward_request("peer_1", req, res); + EXPECT_EQ(res.status, 502); +} + +// ============================================================================= +// Static peer scheme validation tests +// ============================================================================= + +TEST(AggregationManager, rejects_non_http_static_peer) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + + AggregationConfig::PeerConfig ftp_peer; + ftp_peer.url = "ftp://192.168.1.50:8081"; + ftp_peer.name = "ftp_peer"; + config.peers.push_back(ftp_peer); + + AggregationConfig::PeerConfig file_peer; + file_peer.url = "file:///etc/passwd"; + file_peer.name = "file_peer"; + config.peers.push_back(file_peer); + + AggregationConfig::PeerConfig valid_peer; + valid_peer.url = "http://192.168.1.10:8080"; + valid_peer.name = "valid_peer"; + config.peers.push_back(valid_peer); + + AggregationManager manager(config); + + // Only the valid HTTP peer should have been added + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, accepts_localhost_for_static_peer) { + // Static peers allow localhost (unlike mDNS-discovered peers) + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + + AggregationConfig::PeerConfig localhost_peer; + localhost_peer.url = "http://localhost:8081"; + localhost_peer.name = "local_peer"; + config.peers.push_back(localhost_peer); + + AggregationConfig::PeerConfig loopback_peer; + loopback_peer.url = "http://127.0.0.1:8082"; + loopback_peer.name = "loopback_peer"; + config.peers.push_back(loopback_peer); + + AggregationManager manager(config); + + // Both should be accepted for static config + EXPECT_EQ(manager.peer_count(), 2u); +} + +// ============================================================================= +// TLS enforcement tests (require_tls) +// ============================================================================= + +TEST(AggregationManager, require_tls_rejects_http_static_peers) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.require_tls = true; + + AggregationConfig::PeerConfig http_peer; + http_peer.url = "http://192.168.1.10:8080"; + http_peer.name = "insecure_peer"; + config.peers.push_back(http_peer); + + AggregationConfig::PeerConfig https_peer; + https_peer.url = "https://192.168.1.11:8443"; + https_peer.name = "secure_peer"; + config.peers.push_back(https_peer); + + AggregationManager manager(config); + + // Only the HTTPS peer should be accepted + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, require_tls_rejects_http_discovered_peers) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.require_tls = true; + + AggregationManager manager(config); + + // http:// should be rejected + manager.add_discovered_peer("http://192.168.1.50:8081", "http_peer"); + EXPECT_EQ(manager.peer_count(), 0u); + + // https:// should be accepted + manager.add_discovered_peer("https://192.168.1.51:8443", "https_peer"); + EXPECT_EQ(manager.peer_count(), 1u); +} + +TEST(AggregationManager, require_tls_false_accepts_http_peers) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.require_tls = false; + + AggregationConfig::PeerConfig http_peer; + http_peer.url = "http://192.168.1.10:8080"; + http_peer.name = "http_peer"; + config.peers.push_back(http_peer); + + AggregationManager manager(config); + + EXPECT_EQ(manager.peer_count(), 1u); +} + +// ============================================================================= +// forward_auth config tests +// ============================================================================= + +TEST(AggregationManager, forward_auth_config_propagated_to_peer_clients) { + // When forward_auth is false (default), PeerClients should not forward auth headers. + // This is indirectly tested via fan_out_get tests above. + // Here we just verify the config is accepted without error. + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.forward_auth = false; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(DEAD_PORT); + peer.name = "test_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + EXPECT_EQ(manager.peer_count(), 1u); +} + +// ============================================================================= +// Helper: RAII wrapper for a mock HTTP server +// ============================================================================= + +/** + * @brief RAII wrapper for httplib::Server that starts listening on a random port + * and stops + joins on destruction. All mock server tests use this pattern. + */ +class MockPeerServer { + public: + MockPeerServer() = default; + + ~MockPeerServer() { + if (server_) { + server_->stop(); + } + if (thread_.joinable()) { + thread_.join(); + } + } + + // Non-copyable, non-movable + MockPeerServer(const MockPeerServer &) = delete; + MockPeerServer & operator=(const MockPeerServer &) = delete; + + httplib::Server & server() { + if (!server_) { + server_ = std::make_unique(); + } + return *server_; + } + + /** + * @brief Bind to a random port and start listening in a background thread. + * @return The port number the server is listening on. + */ + int start() { + port_ = server_->bind_to_any_port("127.0.0.1"); + thread_ = std::thread([this]() { + server_->listen_after_bind(); + }); + return port_; + } + + int port() const { + return port_; + } + + std::string url() const { + return "http://127.0.0.1:" + std::to_string(port_); + } + + private: + std::unique_ptr server_; + std::thread thread_; + int port_{0}; +}; + +/** + * @brief Install standard entity endpoints on a mock server that return + * the given areas, components, apps, and functions counts. + * Each entity gets a simple {"id":"entity_N","name":"Entity N"} shape. + */ +static void install_entity_endpoints(httplib::Server & svr, size_t num_areas, size_t num_components, size_t num_apps, + size_t num_functions) { + // Health endpoint (required to mark peer as healthy) + svr.Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + + // Areas list + svr.Get("/api/v1/areas", [num_areas](const httplib::Request &, httplib::Response & res) { + nlohmann::json items = nlohmann::json::array(); + for (size_t i = 0; i < num_areas; ++i) { + items.push_back({{"id", "area_" + std::to_string(i)}, {"name", "Area " + std::to_string(i)}}); + } + res.set_content(nlohmann::json({{"items", items}}).dump(), "application/json"); + }); + + // Area subareas (empty for all areas) + svr.Get(R"(/api/v1/areas/([^/]+)/subareas)", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[]})", "application/json"); + }); + + // Components list + svr.Get("/api/v1/components", [num_components](const httplib::Request &, httplib::Response & res) { + nlohmann::json items = nlohmann::json::array(); + for (size_t i = 0; i < num_components; ++i) { + items.push_back({{"id", "comp_" + std::to_string(i)}, {"name", "Component " + std::to_string(i)}}); + } + res.set_content(nlohmann::json({{"items", items}}).dump(), "application/json"); + }); + + // Component detail (for relationship data) + svr.Get(R"(/api/v1/components/([^/]+))", [](const httplib::Request & req, httplib::Response & res) { + // Check it's not a sub-resource path + std::string match = req.matches[1].str(); + if (match.find('/') != std::string::npos) { + res.status = 404; + return; + } + res.set_content(nlohmann::json({{"id", match}, {"name", match}}).dump(), "application/json"); + }); + + // Component subcomponents (empty for all components) + svr.Get(R"(/api/v1/components/([^/]+)/subcomponents)", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[]})", "application/json"); + }); + + // Apps list + svr.Get("/api/v1/apps", [num_apps](const httplib::Request &, httplib::Response & res) { + nlohmann::json items = nlohmann::json::array(); + for (size_t i = 0; i < num_apps; ++i) { + items.push_back({{"id", "app_" + std::to_string(i)}, {"name", "App " + std::to_string(i)}}); + } + res.set_content(nlohmann::json({{"items", items}}).dump(), "application/json"); + }); + + // Functions list + svr.Get("/api/v1/functions", [num_functions](const httplib::Request &, httplib::Response & res) { + nlohmann::json items = nlohmann::json::array(); + for (size_t i = 0; i < num_functions; ++i) { + items.push_back({{"id", "func_" + std::to_string(i)}, {"name", "Function " + std::to_string(i)}}); + } + res.set_content(nlohmann::json({{"items", items}}).dump(), "application/json"); + }); + + // Function detail (for hosts data) + svr.Get(R"(/api/v1/functions/([^/]+))", [](const httplib::Request & req, httplib::Response & res) { + std::string match = req.matches[1].str(); + res.set_content(nlohmann::json({{"id", match}, {"name", match}}).dump(), "application/json"); + }); +} + +// ============================================================================= +// max_entities_per_peer safety limit test +// ============================================================================= + +TEST(AggregationManager, fetch_and_merge_skips_peer_exceeding_entity_limit) { + // Set up a mock server that returns more entities than the safety limit. + // The default limit is 10000; we set a small limit for test speed. + MockPeerServer mock; + // Return 60 entities total: 10 areas + 20 components + 20 apps + 10 functions + install_entity_endpoints(mock.server(), 10, 20, 20, 10); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "big_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + + // Make the peer healthy + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 1u); + + // Use a limit smaller than the 60 entities the peer returns -> should skip + auto result = manager.fetch_and_merge_peer_entities({}, {}, {}, {}, /*max_entities_per_peer=*/50); + + // The peer should have been skipped, so merged result is just the empty locals + EXPECT_TRUE(result.areas.empty()); + EXPECT_TRUE(result.components.empty()); + EXPECT_TRUE(result.apps.empty()); + EXPECT_TRUE(result.functions.empty()); + EXPECT_TRUE(result.routing_table.empty()); +} + +TEST(AggregationManager, fetch_and_merge_accepts_peer_under_entity_limit) { + // Same setup but with a limit higher than the total entities + MockPeerServer mock; + install_entity_endpoints(mock.server(), 2, 3, 4, 1); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "small_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 1u); + + // 2+3+4+1 = 10 entities, limit is 100 -> should accept + auto result = manager.fetch_and_merge_peer_entities({}, {}, {}, {}, /*max_entities_per_peer=*/100); + + // Peer entities should be merged in + EXPECT_EQ(result.areas.size(), 2u); + EXPECT_EQ(result.components.size(), 3u); + EXPECT_EQ(result.apps.size(), 4u); + EXPECT_EQ(result.functions.size(), 1u); + // Remote entities should have routing table entries (apps and components are + // remote-only, so they get routing entries; areas and functions get merged by ID + // but still need routing for non-colliding remote entities) + EXPECT_FALSE(result.routing_table.empty()); +} + +TEST(AggregationManager, fetch_and_merge_uses_default_10k_limit) { + // Verify that the default parameter value is 10000 by calling without explicit limit. + // With a small mock peer, it should always be accepted. + MockPeerServer mock; + install_entity_endpoints(mock.server(), 1, 1, 1, 1); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "normal_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 1u); + + // Use default limit (10000) + auto result = manager.fetch_and_merge_peer_entities({}, {}, {}, {}); + + EXPECT_EQ(result.areas.size(), 1u); + EXPECT_EQ(result.components.size(), 1u); + EXPECT_EQ(result.apps.size(), 1u); + EXPECT_EQ(result.functions.size(), 1u); +} + +// ============================================================================= +// fan_out_get happy-path tests with mock server +// ============================================================================= + +TEST(AggregationManager, fan_out_get_merges_items_from_single_healthy_peer) { + MockPeerServer mock; + mock.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock.server().Get("/api/v1/components", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = { + {"items", {{{"id", "comp_a"}, {"name", "Component A"}}, {{"id", "comp_b"}, {"name", "Component B"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "mock_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 1u); + + auto result = manager.fan_out_get("/api/v1/components", ""); + + ASSERT_TRUE(result.merged_items.is_array()); + EXPECT_EQ(result.merged_items.size(), 2u); + EXPECT_EQ(result.merged_items[0]["id"], "comp_a"); + EXPECT_EQ(result.merged_items[1]["id"], "comp_b"); + EXPECT_FALSE(result.is_partial); + EXPECT_TRUE(result.failed_peers.empty()); +} + +TEST(AggregationManager, fan_out_get_merges_items_from_multiple_peers) { + MockPeerServer mock1; + mock1.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock1.server().Get("/api/v1/apps", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = {{"items", {{{"id", "app_x"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + + MockPeerServer mock2; + mock2.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock2.server().Get("/api/v1/apps", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = {{"items", {{{"id", "app_y"}}, {{"id", "app_z"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + + int port1 = mock1.start(); + int port2 = mock2.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer1; + peer1.url = "http://127.0.0.1:" + std::to_string(port1); + peer1.name = "peer_alpha"; + config.peers.push_back(peer1); + + AggregationConfig::PeerConfig peer2; + peer2.url = "http://127.0.0.1:" + std::to_string(port2); + peer2.name = "peer_beta"; + config.peers.push_back(peer2); + + AggregationManager manager(config); + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 2u); + + auto result = manager.fan_out_get("/api/v1/apps", ""); + + ASSERT_TRUE(result.merged_items.is_array()); + EXPECT_EQ(result.merged_items.size(), 3u); + EXPECT_FALSE(result.is_partial); + EXPECT_TRUE(result.failed_peers.empty()); +} + +TEST(AggregationManager, fan_out_get_partial_results_one_peer_fails) { + // peer_ok returns items, peer_bad returns 500 + MockPeerServer mock_ok; + mock_ok.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock_ok.server().Get("/api/v1/data", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = {{"items", {{{"id", "item_1"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + + MockPeerServer mock_bad; + mock_bad.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock_bad.server().Get("/api/v1/data", [](const httplib::Request &, httplib::Response & res) { + res.status = 500; + res.set_content(R"({"error":"internal"})", "application/json"); + }); + + int port_ok = mock_ok.start(); + int port_bad = mock_bad.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer_ok; + peer_ok.url = "http://127.0.0.1:" + std::to_string(port_ok); + peer_ok.name = "peer_ok"; + config.peers.push_back(peer_ok); + + AggregationConfig::PeerConfig peer_bad; + peer_bad.url = "http://127.0.0.1:" + std::to_string(port_bad); + peer_bad.name = "peer_bad"; + config.peers.push_back(peer_bad); + + AggregationManager manager(config); + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 2u); + + auto result = manager.fan_out_get("/api/v1/data", ""); + + // Should have partial results: items from peer_ok, failure from peer_bad + ASSERT_TRUE(result.merged_items.is_array()); + EXPECT_EQ(result.merged_items.size(), 1u); + EXPECT_EQ(result.merged_items[0]["id"], "item_1"); + EXPECT_TRUE(result.is_partial); + ASSERT_EQ(result.failed_peers.size(), 1u); + EXPECT_EQ(result.failed_peers[0], "peer_bad"); +} + +TEST(AggregationManager, fan_out_get_forwards_auth_header_when_enabled) { + MockPeerServer mock; + std::string captured_auth; + mock.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock.server().Get("/api/v1/secure-data", [&captured_auth](const httplib::Request & req, httplib::Response & res) { + if (req.has_header("Authorization")) { + captured_auth = req.get_header_value("Authorization"); + } + nlohmann::json body = {{"items", {{{"id", "secret"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + config.forward_auth = true; // Enable auth forwarding + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "secure_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + + auto result = manager.fan_out_get("/api/v1/secure-data", "Bearer test-jwt-token"); + + EXPECT_EQ(result.merged_items.size(), 1u); + EXPECT_EQ(captured_auth, "Bearer test-jwt-token"); +} + +TEST(AggregationManager, fan_out_get_does_not_forward_auth_by_default) { + MockPeerServer mock; + bool auth_received = false; + mock.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock.server().Get("/api/v1/secure-data", [&auth_received](const httplib::Request & req, httplib::Response & res) { + auth_received = req.has_header("Authorization"); + nlohmann::json body = {{"items", {{{"id", "data"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + // forward_auth defaults to false + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "noauth_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + + auto result = manager.fan_out_get("/api/v1/secure-data", "Bearer should-not-forward"); + + EXPECT_EQ(result.merged_items.size(), 1u); + EXPECT_FALSE(auth_received); +} + +// ============================================================================= +// Concurrent add/remove peer stress test +// ============================================================================= + +TEST(AggregationManager, concurrent_add_remove_and_health_check_no_crash) { + // Exercise locking by concurrently calling add_discovered_peer, + // remove_discovered_peer, check_all_health, peer_count, and routing + // table operations from multiple threads. The goal is to verify no + // TSAN errors, deadlocks, or crashes. + // + // We avoid check_all_health() here because peers point to non-routable + // IPs that would time out (making the test slow). Instead we exercise + // the shared_lock paths via peer_count/healthy_peer_count/get_peer_status + // which are fast read-only operations. + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 50; // Very short timeout (only matters if health is checked) + config.max_discovered_peers = 1000; // Allow enough room for stress test + + AggregationManager manager(config); + EXPECT_EQ(manager.peer_count(), 0u); + + constexpr int NUM_ITERATIONS = 500; + + // Thread 1: repeatedly add peers + auto adder = std::async(std::launch::async, [&]() { + for (int i = 0; i < NUM_ITERATIONS; ++i) { + manager.add_discovered_peer("http://192.168.1." + std::to_string((i % 254) + 1) + ":8080", + "peer_" + std::to_string(i)); + } + }); + + // Thread 2: repeatedly remove peers (overlapping range with adder) + auto remover = std::async(std::launch::async, [&]() { + for (int i = 0; i < NUM_ITERATIONS; ++i) { + manager.remove_discovered_peer("peer_" + std::to_string(i)); + } + }); + + // Thread 3: repeatedly read peer state (shared lock contention) + auto reader = std::async(std::launch::async, [&]() { + for (int i = 0; i < NUM_ITERATIONS; ++i) { + (void)manager.peer_count(); + (void)manager.healthy_peer_count(); + (void)manager.get_peer_status(); + } + }); + + // Thread 4: routing table mutations (exclusive lock contention) + auto routing = std::async(std::launch::async, [&]() { + for (int i = 0; i < NUM_ITERATIONS; ++i) { + std::unordered_map table; + table["entity_" + std::to_string(i)] = "peer_" + std::to_string(i % 10); + manager.update_routing_table(table); + (void)manager.find_peer_for_entity("entity_" + std::to_string(i)); + } + }); + + adder.get(); + remover.get(); + reader.get(); + routing.get(); + + // If we reach here without deadlock or crash, the test passes. + // The final peer count is non-deterministic due to interleaving, but it + // should be within [0, NUM_ITERATIONS]. + EXPECT_LE(manager.peer_count(), static_cast(NUM_ITERATIONS)); +} + +TEST(AggregationManager, concurrent_fan_out_with_peer_mutations) { + // Similar stress test but includes fan_out_get and routing table operations + MockPeerServer mock; + mock.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock.server().Get("/api/v1/components", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = {{"items", {{{"id", "comp_1"}}}}}; + res.set_content(body.dump(), "application/json"); + }); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 2000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "stable_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + + constexpr int ITERATIONS = 50; + + // Thread 1: fan-out reads + auto fan_out_reader = std::async(std::launch::async, [&]() { + for (int i = 0; i < ITERATIONS; ++i) { + auto result = manager.fan_out_get("/api/v1/components", ""); + // Result should be valid (not crashed) + EXPECT_TRUE(result.merged_items.is_array()); + } + }); + + // Thread 2: routing table mutations + auto routing_writer = std::async(std::launch::async, [&]() { + for (int i = 0; i < ITERATIONS; ++i) { + std::unordered_map table; + table["entity_" + std::to_string(i)] = "stable_peer"; + manager.update_routing_table(table); + (void)manager.find_peer_for_entity("entity_" + std::to_string(i)); + } + }); + + // Thread 3: add and remove discovered peers + auto peer_mutator = std::async(std::launch::async, [&]() { + for (int i = 0; i < ITERATIONS; ++i) { + manager.add_discovered_peer("http://10.0.0." + std::to_string((i % 254) + 1) + ":8080", + "dynamic_" + std::to_string(i)); + manager.remove_discovered_peer("dynamic_" + std::to_string(i)); + } + }); + + fan_out_reader.get(); + routing_writer.get(); + peer_mutator.get(); + + // No crash or deadlock = success + SUCCEED(); +} + +// ============================================================================= +// fetch_all_peer_entities happy-path with mock server +// ============================================================================= + +TEST(AggregationManager, fetch_all_peer_entities_returns_entities_from_healthy_peer) { + MockPeerServer mock; + install_entity_endpoints(mock.server(), 2, 1, 3, 0); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "entity_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + manager.check_all_health(); + ASSERT_EQ(manager.healthy_peer_count(), 1u); + + auto entities = manager.fetch_all_peer_entities(); + + EXPECT_EQ(entities.areas.size(), 2u); + EXPECT_EQ(entities.components.size(), 1u); + EXPECT_EQ(entities.apps.size(), 3u); + EXPECT_EQ(entities.functions.size(), 0u); + + // Verify source tagging + for (const auto & area : entities.areas) { + EXPECT_EQ(area.source, "peer:entity_peer"); + } + for (const auto & app : entities.apps) { + EXPECT_EQ(app.source, "peer:entity_peer"); + } +} + +// ============================================================================= +// forward_request happy-path with mock server +// ============================================================================= + +TEST(AggregationManager, forward_request_proxies_to_correct_peer) { + MockPeerServer mock; + mock.server().Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + mock.server().Get(R"(/api/v1/apps/camera_driver/data)", [](const httplib::Request &, httplib::Response & res) { + nlohmann::json body = {{"temperature", 42.5}, {"status", "ok"}}; + res.set_content(body.dump(), "application/json"); + }); + int port = mock.start(); + + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 5000; + + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:" + std::to_string(port); + peer.name = "proxy_peer"; + config.peers.push_back(peer); + + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/apps/camera_driver/data"; + httplib::Response res; + + manager.forward_request("proxy_peer", req, res); + + EXPECT_EQ(res.status, 200); + auto body = nlohmann::json::parse(res.body, nullptr, false); + ASSERT_FALSE(body.is_discarded()); + EXPECT_DOUBLE_EQ(body["temperature"].get(), 42.5); + EXPECT_EQ(body["status"].get(), "ok"); +} + +// ============================================================================= +// SSRF protection: IPv6 and edge-case loopback rejection +// ============================================================================= + +TEST(AggregationManager, rejects_ipv4_mapped_ipv6_loopback) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + AggregationManager manager(config); + + // IPv4-mapped IPv6 loopback (::ffff:127.0.0.1) + manager.add_discovered_peer("http://[::ffff:127.0.0.1]:8081", "mapped_loopback"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_expanded_ipv6_loopback) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + AggregationManager manager(config); + + // Expanded IPv6 loopback (0:0:0:0:0:0:0:1) + manager.add_discovered_peer("http://[0:0:0:0:0:0:0:1]:8081", "expanded_v6"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_zero_address) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + AggregationManager manager(config); + + // 0.0.0.0 (unspecified, binds all interfaces) + manager.add_discovered_peer("http://0.0.0.0:8081", "zero_addr"); + EXPECT_EQ(manager.peer_count(), 0u); + + // [::] (IPv6 unspecified) + manager.add_discovered_peer("http://[::]:8081", "zero_v6"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +TEST(AggregationManager, rejects_ipv6_link_local) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + AggregationManager manager(config); + + // fe80:: link-local address + manager.add_discovered_peer("http://[fe80::1]:8081", "link_local_v6"); + EXPECT_EQ(manager.peer_count(), 0u); +} + +// ============================================================================= +// max_discovered_peers limit +// ============================================================================= + +TEST(AggregationManager, max_discovered_peers_limits_dynamic_additions) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.max_discovered_peers = 3; + AggregationManager manager(config); + + // Add 3 discovered peers (should all succeed) + manager.add_discovered_peer("http://192.168.1.10:8081", "peer_a"); + manager.add_discovered_peer("http://192.168.1.11:8081", "peer_b"); + manager.add_discovered_peer("http://192.168.1.12:8081", "peer_c"); + EXPECT_EQ(manager.peer_count(), 3u); + + // 4th should be rejected + manager.add_discovered_peer("http://192.168.1.13:8081", "peer_d"); + EXPECT_EQ(manager.peer_count(), 3u); +} + +TEST(AggregationManager, max_discovered_peers_does_not_count_static_peers) { + AggregationConfig config; + config.enabled = true; + config.timeout_ms = 200; + config.max_discovered_peers = 2; + + // Add 2 static peers + AggregationConfig::PeerConfig static1; + static1.url = "http://10.0.0.1:8080"; + static1.name = "static_1"; + config.peers.push_back(static1); + + AggregationConfig::PeerConfig static2; + static2.url = "http://10.0.0.2:8080"; + static2.name = "static_2"; + config.peers.push_back(static2); + + AggregationManager manager(config); + EXPECT_EQ(manager.peer_count(), 2u); + + // Should still be able to add 2 discovered peers + manager.add_discovered_peer("http://192.168.1.10:8081", "disc_1"); + manager.add_discovered_peer("http://192.168.1.11:8081", "disc_2"); + EXPECT_EQ(manager.peer_count(), 4u); + + // 3rd discovered peer exceeds limit + manager.add_discovered_peer("http://192.168.1.12:8081", "disc_3"); + EXPECT_EQ(manager.peer_count(), 4u); +} + +// ============================================================================= +// Forward request path validation +// ============================================================================= + +TEST(AggregationManager, forward_rejects_non_api_path) { + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/internal/metrics"; + httplib::Response res; + + manager.forward_request("peer_0", req, res); + + EXPECT_EQ(res.status, 400); + auto body_json = nlohmann::json::parse(res.body, nullptr, false); + ASSERT_FALSE(body_json.is_discarded()); + EXPECT_EQ(body_json["error_code"], "invalid-request"); +} + +TEST(AggregationManager, forward_rejects_root_path) { + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/"; + httplib::Response res; + + manager.forward_request("peer_0", req, res); + + EXPECT_EQ(res.status, 400); +} + +TEST(AggregationManager, forward_accepts_api_v1_path) { + auto config = make_config(1); + AggregationManager manager(config); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/components/abc/data"; + httplib::Response res; + + manager.forward_request("peer_0", req, res); + + // Should get 502 (peer unreachable), not 400 (path rejected) + EXPECT_EQ(res.status, 502); +} diff --git a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp index a8c883caa..a8a0bc077 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_handlers.cpp @@ -271,7 +271,9 @@ TEST_F(DiscoveryHandlersFixtureTest, ListAreasReturnsSeededItems) { EXPECT_EQ(res.get_header_value("Content-Type"), "application/json"); auto body = parse_json(res); ASSERT_TRUE(body.contains("items")); - ASSERT_EQ(body["items"].size(), 2); + // "sensors" has parent_area "vehicle", so it's filtered from top-level list + ASSERT_EQ(body["items"].size(), 1); + EXPECT_EQ(body["items"][0]["id"], "vehicle"); } // @verifies REQ_INTEROP_003 @@ -415,7 +417,8 @@ TEST_F(DiscoveryHandlersFixtureTest, ListComponentsReturnsMetadata) { handlers_->handle_list_components(req, res); auto body = parse_json(res); - ASSERT_EQ(body["items"].size(), 2); + // lidar_unit has parent_component_id, so it's filtered from top-level list + ASSERT_EQ(body["items"].size(), 1); EXPECT_EQ(body["items"][0]["id"], "main_ecu"); EXPECT_EQ(body["items"][0]["description"], "Vehicle control unit"); EXPECT_EQ(body["items"][0]["x-medkit"]["source"], "manifest"); @@ -492,7 +495,8 @@ TEST_F(DiscoveryHandlersFixtureTest, GetHostsReturnsHostedApps) { ASSERT_EQ(body["items"].size(), 1); EXPECT_EQ(body["items"][0]["id"], "planner"); EXPECT_EQ(body["items"][0]["x-medkit"]["source"], "manifest"); - EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], false); + // Reads from cache where planner app has is_online=true (set in SetUp) + EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true); } // @verifies REQ_INTEROP_007 @@ -696,7 +700,8 @@ TEST_F(DiscoveryHandlersFixtureTest, AppDependsOnReturnsResolvedAndMissingDepend ASSERT_EQ(body["items"].size(), 2); EXPECT_EQ(body["items"][0]["id"], "planner"); EXPECT_EQ(body["items"][0]["x-medkit"]["source"], "manifest"); - EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true); // cache has enriched is_online from SetUp + // Reads from cache where planner app has is_online=true (set in SetUp) + EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true); EXPECT_EQ(body["items"][1]["id"], "ghost_app"); EXPECT_EQ(body["items"][1]["x-medkit"]["missing"], true); EXPECT_EQ(body["_links"]["app"], "/api/v1/apps/mapper"); @@ -800,6 +805,7 @@ TEST_F(DiscoveryHandlersFixtureTest, FunctionHostsReturnsHostingApps) { ASSERT_EQ(body["items"].size(), 1); EXPECT_EQ(body["items"][0]["id"], "planner"); EXPECT_EQ(body["items"][0]["x-medkit"]["source"], "manifest"); - EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true); // cache has enriched is_online from SetUp + // Reads from cache where planner app has is_online=true (set in SetUp) + EXPECT_EQ(body["items"][0]["x-medkit"]["is_online"], true); EXPECT_EQ(body["_links"]["function"], "/api/v1/functions/navigation"); } diff --git a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp index 2892102b5..aca8e1853 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_manager.cpp @@ -14,12 +14,9 @@ #include -#include #include #include -#include #include -#include #include "ros2_medkit_gateway/discovery/discovery_manager.hpp" #include "ros2_medkit_gateway/native_topic_sampler.hpp" @@ -59,231 +56,44 @@ class DiscoveryManagerTest : public ::testing::Test { std::unique_ptr discovery_manager_; }; -TEST_F(DiscoveryManagerTest, DiscoverTopicComponents_ReturnsEmptyWhenNoTopics) { - // In a clean environment with only system topics, should return empty - auto components = discovery_manager_->discover_topic_components(); +TEST_F(DiscoveryManagerTest, DiscoverComponents_RuntimeOnlyReturnsHostComponent) { + // With default config (host info provider enabled), should return single host component + ros2_medkit_gateway::DiscoveryConfig config; + config.runtime.default_component_enabled = true; + discovery_manager_->initialize(config); - // System topics are filtered, so we may get empty list - // Each component should have source="topic" if present - for (const auto & comp : components) { - EXPECT_EQ(comp.source, "topic") << "Topic-based component should have source='topic'"; - } -} - -TEST_F(DiscoveryManagerTest, DiscoverTopicComponents_SetsSourceField) { - auto components = discovery_manager_->discover_topic_components(); - - // All topic-based components should have source="topic" - for (const auto & comp : components) { - EXPECT_EQ(comp.source, "topic"); - EXPECT_FALSE(comp.id.empty()); - EXPECT_FALSE(comp.namespace_path.empty()); - EXPECT_FALSE(comp.fqn.empty()); - } -} - -TEST_F(DiscoveryManagerTest, DiscoverComponents_NodeBasedHaveSourceSynthetic) { - // With default config (create_synthetic_components=true), components are synthetic auto components = discovery_manager_->discover_components(); - - // Synthetic components (grouped by namespace) have source="synthetic" - // If no runtime nodes, we may have the test node as well - for (const auto & comp : components) { - // Components can be "synthetic" (namespace-grouped) or "node" (legacy) - EXPECT_TRUE(comp.source == "synthetic" || comp.source == "node") - << "Component should have source='synthetic' or 'node', got: " << comp.source; - } -} - -// ============================================================================= -// Integration test with publishers (topic-based discovery) -// ============================================================================= - -class DiscoveryManagerWithPublishersTest : public ::testing::Test { - protected: - static void SetUpTestSuite() { - rclcpp::init(0, nullptr); - } - - static void TearDownTestSuite() { - rclcpp::shutdown(); - } - - void SetUp() override { - // Create main node for discovery - node_ = std::make_shared("test_discovery_node"); - topic_sampler_ = std::make_shared(node_.get()); - discovery_manager_ = std::make_unique(node_.get()); - discovery_manager_->set_topic_sampler(topic_sampler_.get()); - - // Create publishers on namespaced topics (simulating Isaac Sim) - // These topics have no associated nodes in those namespaces - pub1_ = node_->create_publisher("/robot_alpha/status", 10); - pub2_ = node_->create_publisher("/robot_alpha/odom", 10); - pub3_ = node_->create_publisher("/robot_beta/status", 10); - - // Simulate root-namespace node publishing with node-name prefix - // (like /fault_manager publishing /fault_manager/events) - pub_root_node_ = node_->create_publisher("/test_discovery_node/events", 10); - - // Allow time for graph discovery - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - rclcpp::spin_some(node_); - } - - void TearDown() override { - pub1_.reset(); - pub2_.reset(); - pub3_.reset(); - pub_root_node_.reset(); - discovery_manager_.reset(); - topic_sampler_.reset(); - node_.reset(); - } - - std::shared_ptr node_; - std::shared_ptr topic_sampler_; - std::unique_ptr discovery_manager_; - rclcpp::Publisher::SharedPtr pub1_; - rclcpp::Publisher::SharedPtr pub2_; - rclcpp::Publisher::SharedPtr pub3_; - rclcpp::Publisher::SharedPtr pub_root_node_; -}; - -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverTopicComponents_FindsNamespacedTopics) { - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - rclcpp::spin_some(node_); - - auto components = discovery_manager_->discover_topic_components(); - - // Should discover robot_alpha and robot_beta namespaces - bool found_alpha = false; - bool found_beta = false; - - for (const auto & comp : components) { - if (comp.id == "robot_alpha") { - found_alpha = true; - EXPECT_EQ(comp.source, "topic"); - EXPECT_EQ(comp.namespace_path, "/robot_alpha"); - EXPECT_EQ(comp.area, "robot_alpha"); - // Should have at least 2 topics - EXPECT_GE(comp.topics.publishes.size(), 2u); - } - if (comp.id == "robot_beta") { - found_beta = true; - EXPECT_EQ(comp.source, "topic"); - EXPECT_EQ(comp.namespace_path, "/robot_beta"); - EXPECT_GE(comp.topics.publishes.size(), 1u); - } + EXPECT_EQ(components.size(), 1u) << "Should return exactly one host-derived component"; + if (!components.empty()) { + EXPECT_EQ(components[0].source, "runtime") << "Component should come from HostInfoProvider with source='runtime'"; } - - EXPECT_TRUE(found_alpha) << "Should discover robot_alpha from topics"; - EXPECT_TRUE(found_beta) << "Should discover robot_beta from topics"; } -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverTopicComponents_ComponentHasCorrectTopics) { - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - rclcpp::spin_some(node_); - - auto components = discovery_manager_->discover_topic_components(); - - for (const auto & comp : components) { - if (comp.id == "robot_alpha") { - bool has_status = false; - bool has_odom = false; +TEST_F(DiscoveryManagerTest, DiscoverComponents_EmptyWhenHostInfoDisabled) { + // With host info provider disabled, no components in runtime mode + ros2_medkit_gateway::DiscoveryConfig config; + config.runtime.default_component_enabled = false; + discovery_manager_->initialize(config); - for (const auto & topic : comp.topics.publishes) { - if (topic == "/robot_alpha/status") { - has_status = true; - } - if (topic == "/robot_alpha/odom") { - has_odom = true; - } - } - - EXPECT_TRUE(has_status) << "robot_alpha should have /robot_alpha/status topic"; - EXPECT_TRUE(has_odom) << "robot_alpha should have /robot_alpha/odom topic"; - } - } + auto components = discovery_manager_->discover_components(); + EXPECT_TRUE(components.empty()) << "Components should be empty when host info provider is disabled"; } -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverAreas_IncludesTopicBasedAreas) { - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - rclcpp::spin_some(node_); - +TEST_F(DiscoveryManagerTest, DiscoverAreas_AlwaysEmptyInRuntimeMode) { auto areas = discovery_manager_->discover_areas(); - - bool found_alpha = false; - bool found_beta = false; - - for (const auto & area : areas) { - if (area.id == "robot_alpha") { - found_alpha = true; - } - if (area.id == "robot_beta") { - found_beta = true; - } - } - - EXPECT_TRUE(found_alpha) << "Areas should include robot_alpha from topics"; - EXPECT_TRUE(found_beta) << "Areas should include robot_beta from topics"; + EXPECT_TRUE(areas.empty()) << "Areas should always be empty in runtime mode - Areas come from manifest only"; } -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverAreas_DoesNotCreateAreaForRootNamespaceNodeName) { - // Root-namespace nodes publish topics with their node name as prefix - // (e.g., /fault_manager publishes /fault_manager/events). Topic-based - // discovery must not create a synthetic area from that prefix - the node - // belongs to area "root", not to a per-node area. - std::this_thread::sleep_for(std::chrono::milliseconds(200)); - rclcpp::spin_some(node_); - - auto areas = discovery_manager_->discover_areas(); - - bool found_alpha = false; - bool found_beta = false; - for (const auto & area : areas) { - EXPECT_NE(area.id, "test_discovery_node") << "Root-namespace node name should not appear as synthetic area"; - if (area.id == "robot_alpha") { - found_alpha = true; - } - if (area.id == "robot_beta") { - found_beta = true; +TEST_F(DiscoveryManagerTest, DiscoverFunctions_CreatedFromNamespaces) { + // Default config has create_functions_from_namespaces=true + auto functions = discovery_manager_->discover_functions(); + // Should find at least "root" function from the discovery node's namespace + bool found_root = false; + for (const auto & func : functions) { + if (func.id == "root") { + found_root = true; + EXPECT_EQ(func.source, "runtime"); } } - EXPECT_TRUE(found_alpha) << "Legitimate area robot_alpha should survive root-node filter"; - EXPECT_TRUE(found_beta) << "Legitimate area robot_beta should survive root-node filter"; -} - -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverTopicComponents_DoesNotDuplicateNodeNamespaces) { - // The discovery manager's own node is in root namespace - // It should not create a topic-based component for root namespace - - auto topic_components = discovery_manager_->discover_topic_components(); - - for (const auto & comp : topic_components) { - // Topic-based components should not be in root namespace - EXPECT_NE(comp.namespace_path, "/") << "Topic-based component should not be in root namespace"; - // Also check it's not duplicating test_discovery_node's namespace - // (which is root "/") - EXPECT_FALSE(comp.id.empty()); - } -} - -TEST_F(DiscoveryManagerWithPublishersTest, DiscoverTopicComponents_DoesNotDuplicateRootNamespaceNodeTopics) { - // Root namespace nodes publishing topics with matching prefix - // should not create duplicate topic-based components. - // - // Create a publisher with topic prefix matching the test node's name. - // The test node is named "test_discovery_node" and in root namespace. - auto pub = node_->create_publisher("/test_discovery_node/status", 10); - - rclcpp::spin_some(node_); - - auto topic_components = discovery_manager_->discover_topic_components(); - - // Should NOT create a topic-based component for "test_discovery_node" - // because there's already a node with that name in root namespace - for (const auto & comp : topic_components) { - EXPECT_NE(comp.id, "test_discovery_node") << "Should not create duplicate component for root namespace node"; - } + EXPECT_TRUE(found_root) << "Should discover 'root' function from namespace grouping"; } diff --git a/src/ros2_medkit_gateway/test/test_discovery_models.cpp b/src/ros2_medkit_gateway/test/test_discovery_models.cpp index 88e5701a4..4d2c4a369 100644 --- a/src/ros2_medkit_gateway/test/test_discovery_models.cpp +++ b/src/ros2_medkit_gateway/test/test_discovery_models.cpp @@ -16,7 +16,7 @@ * @file test_discovery_models.cpp * @brief Unit tests for SOVD discovery model serialization * - * @verifies REQ_DISCOVERY_002 App/Function model serialization + * @verifies REQ_INTEROP_003 */ #include @@ -56,6 +56,7 @@ class AreaModelTest : public ::testing::Test { Area area_; }; +// @verifies REQ_INTEROP_003 TEST_F(AreaModelTest, ToJson_ContainsRequiredFields) { json j = area_.to_json(); @@ -130,6 +131,7 @@ class ComponentModelTest : public ::testing::Test { Component comp_; }; +// @verifies REQ_INTEROP_003 TEST_F(ComponentModelTest, ToJson_ContainsRequiredFields) { json j = comp_.to_json(); @@ -205,6 +207,7 @@ class AppModelTest : public ::testing::Test { App app_; }; +// @verifies REQ_INTEROP_003 TEST_F(AppModelTest, ToJson_ContainsRequiredFields) { json j = app_.to_json(); @@ -367,6 +370,7 @@ class FunctionModelTest : public ::testing::Test { Function func_; }; +// @verifies REQ_INTEROP_003 TEST_F(FunctionModelTest, ToJson_ContainsRequiredFields) { json j = func_.to_json(); diff --git a/src/ros2_medkit_gateway/test/test_entity_merger.cpp b/src/ros2_medkit_gateway/test/test_entity_merger.cpp new file mode 100644 index 000000000..90854811e --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_entity_merger.cpp @@ -0,0 +1,467 @@ +// 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 "ros2_medkit_gateway/aggregation/entity_merger.hpp" + +using namespace ros2_medkit_gateway; + +// ============================================================================= +// Helper factories - C++17 compatible (no designated initializers) +// ============================================================================= + +static Area make_area(const std::string & id, const std::string & name = "", const std::string & source = "") { + Area a; + a.id = id; + a.name = name.empty() ? id : name; + a.source = source; + return a; +} + +static Function make_function(const std::string & id, const std::vector & hosts, + const std::string & source = "manifest") { + Function f; + f.id = id; + f.name = id; + f.hosts = hosts; + f.source = source; + return f; +} + +static Component make_component(const std::string & id, const std::string & area = "", + const std::string & source = "node") { + Component c; + c.id = id; + c.name = id; + c.area = area; + c.source = source; + return c; +} + +static App make_app(const std::string & id, const std::string & component_id = "", + const std::string & source = "manifest") { + App a; + a.id = id; + a.name = id; + a.component_id = component_id; + a.source = source; + return a; +} + +// ============================================================================= +// Area merge tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(EntityMerger, areas_merge_by_id) { + EntityMerger merger("peer_a"); + + Area local_area = make_area("powertrain", "Powertrain System"); + local_area.tags = {"engine", "transmission"}; + + Area remote_area = make_area("powertrain", "Powertrain"); + remote_area.tags = {"engine", "drivetrain"}; // "engine" overlaps + + auto result = merger.merge_areas({local_area}, {remote_area}); + + // Same ID -> one entity + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].id, "powertrain"); + // Name stays as local + EXPECT_EQ(result[0].name, "Powertrain System"); + + // Tags merged without duplicates: engine, transmission, drivetrain + EXPECT_EQ(result[0].tags.size(), 3u); + auto has_tag = [&](const std::string & tag) { + return std::find(result[0].tags.begin(), result[0].tags.end(), tag) != result[0].tags.end(); + }; + EXPECT_TRUE(has_tag("engine")); + EXPECT_TRUE(has_tag("transmission")); + EXPECT_TRUE(has_tag("drivetrain")); +} + +TEST(EntityMerger, areas_no_collision_both_kept) { + EntityMerger merger("peer_a"); + + auto local_area = make_area("powertrain"); + auto remote_area = make_area("chassis"); + + auto result = merger.merge_areas({local_area}, {remote_area}); + + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].id, "powertrain"); + EXPECT_EQ(result[1].id, "chassis"); + // Remote-only area gets source tagged + EXPECT_EQ(result[1].source, "peer:peer_a"); +} + +TEST(EntityMerger, areas_merge_takes_remote_description_when_local_empty) { + EntityMerger merger("peer_a"); + + Area local_area = make_area("powertrain"); + // local has no description + + Area remote_area = make_area("powertrain"); + remote_area.description = "Powertrain system for engine and drivetrain"; + + auto result = merger.merge_areas({local_area}, {remote_area}); + + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].description, "Powertrain system for engine and drivetrain"); +} + +// ============================================================================= +// Function merge tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(EntityMerger, functions_merge_by_id_combining_hosts) { + EntityMerger merger("peer_b"); + + auto local_func = make_function("navigation", {"planner_app", "controller_app"}); + auto remote_func = make_function("navigation", {"controller_app", "localization_app"}); + + auto result = merger.merge_functions({local_func}, {remote_func}); + + // Same ID -> one function + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].id, "navigation"); + + // Hosts combined without duplicates: planner_app, controller_app, localization_app + EXPECT_EQ(result[0].hosts.size(), 3u); + auto has_host = [&](const std::string & host) { + return std::find(result[0].hosts.begin(), result[0].hosts.end(), host) != result[0].hosts.end(); + }; + EXPECT_TRUE(has_host("planner_app")); + EXPECT_TRUE(has_host("controller_app")); + EXPECT_TRUE(has_host("localization_app")); +} + +TEST(EntityMerger, functions_no_collision_both_kept) { + EntityMerger merger("peer_b"); + + auto local_func = make_function("navigation", {"planner_app"}); + auto remote_func = make_function("perception", {"camera_app"}); + + auto result = merger.merge_functions({local_func}, {remote_func}); + + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].id, "navigation"); + EXPECT_EQ(result[1].id, "perception"); + EXPECT_EQ(result[1].source, "peer:peer_b"); +} + +// ============================================================================= +// Component merge tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(EntityMerger, components_merge_by_id) { + EntityMerger merger("subsystem_b"); + + auto local_comp = make_component("engine_ctrl", "powertrain"); + local_comp.tags = {"engine", "control"}; + + auto remote_comp = make_component("engine_ctrl", "powertrain"); + remote_comp.tags = {"control", "ecu"}; + + auto result = merger.merge_components({local_comp}, {remote_comp}); + + // Same ID -> one entity (merged) + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].id, "engine_ctrl"); + EXPECT_EQ(result[0].source, "node"); // local source preserved + + // Tags merged without duplicates: engine, control, ecu + EXPECT_EQ(result[0].tags.size(), 3u); + auto has_tag = [&](const std::string & tag) { + return std::find(result[0].tags.begin(), result[0].tags.end(), tag) != result[0].tags.end(); + }; + EXPECT_TRUE(has_tag("engine")); + EXPECT_TRUE(has_tag("control")); + EXPECT_TRUE(has_tag("ecu")); +} + +TEST(EntityMerger, components_merge_takes_remote_description_when_local_empty) { + EntityMerger merger("subsystem_b"); + + auto local_comp = make_component("engine_ctrl", "powertrain"); + // local has no description + + auto remote_comp = make_component("engine_ctrl", "powertrain"); + remote_comp.description = "Engine control unit for powertrain management"; + + auto result = merger.merge_components({local_comp}, {remote_comp}); + + ASSERT_EQ(result.size(), 1u); + EXPECT_EQ(result[0].description, "Engine control unit for powertrain management"); +} + +TEST(EntityMerger, components_no_collision_no_prefix) { + EntityMerger merger("subsystem_b"); + + auto local_comp = make_component("engine_ctrl"); + auto remote_comp = make_component("brake_ctrl"); + + auto result = merger.merge_components({local_comp}, {remote_comp}); + + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].id, "engine_ctrl"); + EXPECT_EQ(result[1].id, "brake_ctrl"); // No prefix needed + EXPECT_EQ(result[1].source, "peer:subsystem_b"); +} + +// ============================================================================= +// App merge tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(EntityMerger, apps_prefix_on_collision) { + EntityMerger merger("subsystem_b"); + + auto local_app = make_app("camera_driver", "perception_comp"); + auto remote_app = make_app("camera_driver", "perception_comp"); + + auto result = merger.merge_apps({local_app}, {remote_app}); + + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].id, "camera_driver"); + EXPECT_EQ(result[0].source, "manifest"); // local unchanged + EXPECT_EQ(result[0].component_id, "perception_comp"); // local keeps original + + EXPECT_EQ(result[1].id, "subsystem_b__camera_driver"); + EXPECT_EQ(result[1].name, "subsystem_b__camera_driver"); + EXPECT_EQ(result[1].source, "peer:subsystem_b"); + // Remote app's component_id remapped to peer name + EXPECT_EQ(result[1].component_id, "subsystem_b"); +} + +TEST(EntityMerger, no_collision_no_prefix) { + EntityMerger merger("subsystem_b"); + + auto local_app = make_app("planner"); + auto remote_app = make_app("localizer"); + + auto result = merger.merge_apps({local_app}, {remote_app}); + + ASSERT_EQ(result.size(), 2u); + EXPECT_EQ(result[0].id, "planner"); + EXPECT_EQ(result[1].id, "localizer"); // No prefix + EXPECT_EQ(result[1].source, "peer:subsystem_b"); + // Remote app's component_id remapped to peer name + EXPECT_EQ(result[1].component_id, "subsystem_b"); +} + +// ============================================================================= +// Routing table tests +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(EntityMerger, builds_routing_table_for_remote_entities) { + EntityMerger merger("peer_x"); + + // Remote-only app (no collision) should get routing entry + auto local_apps = std::vector{make_app("local_app")}; + auto remote_apps = std::vector{make_app("remote_app")}; + + merger.merge_apps(local_apps, remote_apps); + + const auto & table = merger.get_routing_table(); + ASSERT_EQ(table.count("remote_app"), 1u); + EXPECT_EQ(table.at("remote_app"), "peer_x"); +} + +TEST(EntityMerger, routing_table_uses_prefixed_id_on_app_collision) { + EntityMerger merger("peer_x"); + + // Same ID -> collision -> remote gets prefixed (Apps use prefix strategy) + auto local_apps = std::vector{make_app("shared_app")}; + auto remote_apps = std::vector{make_app("shared_app")}; + + merger.merge_apps(local_apps, remote_apps); + + const auto & table = merger.get_routing_table(); + + // The routing table should use the prefixed ID + ASSERT_EQ(table.count("peer_x__shared_app"), 1u); + EXPECT_EQ(table.at("peer_x__shared_app"), "peer_x"); + + // The original ID should NOT be in the routing table (it's local) + EXPECT_EQ(table.count("shared_app"), 0u); +} + +TEST(EntityMerger, merged_components_not_in_routing_table) { + EntityMerger merger("peer_x"); + + // Same component ID -> merged, should NOT appear in routing table + auto local_comps = std::vector{make_component("robot-alpha")}; + auto remote_comps = std::vector{make_component("robot-alpha")}; + + merger.merge_components(local_comps, remote_comps); + + const auto & table = merger.get_routing_table(); + EXPECT_EQ(table.count("robot-alpha"), 0u); +} + +TEST(EntityMerger, remote_only_component_gets_routing_entry) { + EntityMerger merger("peer_x"); + + auto local_comps = std::vector{make_component("engine_ctrl")}; + auto remote_comps = std::vector{make_component("brake_ctrl")}; + + merger.merge_components(local_comps, remote_comps); + + const auto & table = merger.get_routing_table(); + ASSERT_EQ(table.count("brake_ctrl"), 1u); + EXPECT_EQ(table.at("brake_ctrl"), "peer_x"); +} + +TEST(EntityMerger, merged_areas_not_in_routing_table) { + EntityMerger merger("peer_x"); + + // Same area ID -> merged, should NOT appear in routing table + auto local_areas = std::vector{make_area("powertrain")}; + auto remote_areas = std::vector{make_area("powertrain")}; + + merger.merge_areas(local_areas, remote_areas); + + const auto & table = merger.get_routing_table(); + EXPECT_EQ(table.count("powertrain"), 0u); +} + +TEST(EntityMerger, merged_functions_not_in_routing_table) { + EntityMerger merger("peer_x"); + + auto local_funcs = std::vector{make_function("navigation", {"app_a"})}; + auto remote_funcs = std::vector{make_function("navigation", {"app_b"})}; + + merger.merge_functions(local_funcs, remote_funcs); + + const auto & table = merger.get_routing_table(); + EXPECT_EQ(table.count("navigation"), 0u); +} + +TEST(EntityMerger, remote_only_area_gets_routing_entry) { + EntityMerger merger("peer_x"); + + auto local_areas = std::vector{make_area("powertrain")}; + auto remote_areas = std::vector{make_area("chassis")}; + + merger.merge_areas(local_areas, remote_areas); + + const auto & table = merger.get_routing_table(); + ASSERT_EQ(table.count("chassis"), 1u); + EXPECT_EQ(table.at("chassis"), "peer_x"); +} + +// ============================================================================= +// Source tagging tests +// ============================================================================= + +TEST(EntityMerger, remote_source_tagged) { + EntityMerger merger("robot_arm"); + + // Test across all entity types + auto areas = merger.merge_areas({}, {make_area("workspace")}); + ASSERT_EQ(areas.size(), 1u); + EXPECT_EQ(areas[0].source, "peer:robot_arm"); + + auto funcs = merger.merge_functions({}, {make_function("grasp", {"gripper_app"})}); + ASSERT_EQ(funcs.size(), 1u); + EXPECT_EQ(funcs[0].source, "peer:robot_arm"); + + auto comps = merger.merge_components({}, {make_component("joint_ctrl")}); + ASSERT_EQ(comps.size(), 1u); + EXPECT_EQ(comps[0].source, "peer:robot_arm"); + + auto apps = merger.merge_apps({}, {make_app("trajectory_planner")}); + ASSERT_EQ(apps.size(), 1u); + EXPECT_EQ(apps[0].source, "peer:robot_arm"); +} + +// ============================================================================= +// Edge cases +// ============================================================================= + +TEST(EntityMerger, empty_local_returns_remote_only) { + EntityMerger merger("peer_z"); + + auto areas = merger.merge_areas({}, {make_area("remote_area")}); + ASSERT_EQ(areas.size(), 1u); + EXPECT_EQ(areas[0].id, "remote_area"); + EXPECT_EQ(areas[0].source, "peer:peer_z"); +} + +TEST(EntityMerger, empty_remote_returns_local_only) { + EntityMerger merger("peer_z"); + + auto areas = merger.merge_areas({make_area("local_area")}, {}); + ASSERT_EQ(areas.size(), 1u); + EXPECT_EQ(areas[0].id, "local_area"); +} + +TEST(EntityMerger, both_empty_returns_empty) { + EntityMerger merger("peer_z"); + + auto areas = merger.merge_areas({}, {}); + EXPECT_TRUE(areas.empty()); + + auto funcs = merger.merge_functions({}, {}); + EXPECT_TRUE(funcs.empty()); + + auto comps = merger.merge_components({}, {}); + EXPECT_TRUE(comps.empty()); + + auto apps = merger.merge_apps({}, {}); + EXPECT_TRUE(apps.empty()); +} + +TEST(EntityMerger, multiple_remote_entities_all_routed) { + EntityMerger merger("peer_m"); + + auto local_apps = std::vector{make_app("local_only")}; + auto remote_apps = std::vector{make_app("remote_a"), make_app("remote_b")}; + + merger.merge_apps(local_apps, remote_apps); + + const auto & table = merger.get_routing_table(); + EXPECT_EQ(table.size(), 2u); + EXPECT_EQ(table.at("remote_a"), "peer_m"); + EXPECT_EQ(table.at("remote_b"), "peer_m"); +} + +TEST(EntityMerger, separator_constant_is_double_underscore) { + EXPECT_STREQ(EntityMerger::SEPARATOR, "__"); +} + +TEST(EntityMerger, routing_table_accumulates_across_merge_calls) { + EntityMerger merger("peer_acc"); + + merger.merge_apps({}, {make_app("app_a")}); + merger.merge_components({}, {make_component("comp_b")}); + merger.merge_areas({}, {make_area("area_c")}); + merger.merge_functions({}, {make_function("func_d", {"app_x"})}); + + const auto & table = merger.get_routing_table(); + EXPECT_EQ(table.size(), 4u); + EXPECT_EQ(table.count("app_a"), 1u); + EXPECT_EQ(table.count("comp_b"), 1u); + EXPECT_EQ(table.count("area_c"), 1u); + EXPECT_EQ(table.count("func_d"), 1u); +} diff --git a/src/ros2_medkit_gateway/test/test_function_resource_collections.cpp b/src/ros2_medkit_gateway/test/test_function_resource_collections.cpp new file mode 100644 index 000000000..c3823feec --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_function_resource_collections.cpp @@ -0,0 +1,465 @@ +// 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 "ros2_medkit_gateway/models/aggregation_service.hpp" +#include "ros2_medkit_gateway/models/entity_capabilities.hpp" +#include "ros2_medkit_gateway/models/entity_types.hpp" +#include "ros2_medkit_gateway/models/thread_safe_entity_cache.hpp" + +using namespace ros2_medkit_gateway; +// nlohmann::json is already aliased as 'json' in the ros2_medkit_gateway namespace + +// ============================================================================ +// Test Helper Functions - avoid C++20 designated initializers for C++17 compat +// ============================================================================ + +namespace { + +Area make_area(const std::string & id, const std::string & name) { + Area a; + a.id = id; + a.name = name; + return a; +} + +Component make_component(const std::string & id, const std::string & name, const std::string & area) { + Component c; + c.id = id; + c.name = name; + c.area = area; + return c; +} + +App make_app(const std::string & id, const std::string & name, const std::string & component_id) { + App a; + a.id = id; + a.name = name; + a.component_id = component_id; + return a; +} + +Function make_function(const std::string & id, const std::string & name, const std::vector & hosts) { + Function f; + f.id = id; + f.name = name; + f.hosts = hosts; + return f; +} + +ServiceInfo make_service(const std::string & name, const std::string & full_path) { + ServiceInfo s; + s.name = name; + s.full_path = full_path; + return s; +} + +} // namespace + +// ============================================================================ +// Function Entity Capabilities Tests +// ============================================================================ + +class FunctionResourceCollections : public ::testing::Test { + protected: + void SetUp() override { + // Build entity hierarchy: + // + // Area: perception + // Component: sensor_stack + // App: camera_driver (services: /camera/start_capture) + // App: lidar_proc (services: /lidar/start_scan) + // + // Function: sensing (hosts: camera_driver, lidar_proc) + + areas_ = {make_area("perception", "Perception Area")}; + + components_.push_back(make_component("sensor_stack", "Sensor Stack", "perception")); + + apps_.push_back(make_app("camera_driver", "Camera Driver", "sensor_stack")); + apps_[0].services = {make_service("start_capture", "/camera/start_capture")}; + apps_[0].topics.publishes = {"/camera/image", "/camera/info"}; + apps_[0].topics.subscribes = {"/camera/enable"}; + apps_[0].ros_binding = {"camera_driver", "/perception", ""}; + + apps_.push_back(make_app("lidar_proc", "LiDAR Processor", "sensor_stack")); + apps_[1].services = {make_service("start_scan", "/lidar/start_scan")}; + apps_[1].topics.publishes = {"/lidar/points"}; + apps_[1].topics.subscribes = {"/camera/image"}; + apps_[1].ros_binding = {"lidar_proc", "/perception", ""}; + + functions_ = {make_function("sensing", "Sensing", {"camera_driver", "lidar_proc"})}; + + cache_.update_all(areas_, components_, apps_, functions_); + service_ = std::make_unique(&cache_); + } + + ThreadSafeEntityCache cache_; + std::unique_ptr service_; + std::vector areas_; + std::vector components_; + std::vector apps_; + std::vector functions_; +}; + +// ============================================================================ +// EntityCapabilities: Function supports aggregated collections +// ============================================================================ + +TEST_F(FunctionResourceCollections, FunctionSupportsFaults) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsLogs) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::LOGS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsData) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::DATA)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::DATA)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsOperations) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::OPERATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::OPERATIONS)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsConfigurations) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsBulkData) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::BULK_DATA)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); +} + +TEST_F(FunctionResourceCollections, FunctionSupportsCyclicSubscriptions) { + auto caps = EntityCapabilities::for_type(SovdEntityType::FUNCTION); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::CYCLIC_SUBSCRIPTIONS)); +} + +// ============================================================================ +// EntityCapabilities: Area supports aggregated collections +// ============================================================================ + +TEST_F(FunctionResourceCollections, AreaSupportsFaults) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::FAULTS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::FAULTS)); +} + +TEST_F(FunctionResourceCollections, AreaSupportsLogs) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::LOGS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::LOGS)); +} + +TEST_F(FunctionResourceCollections, AreaSupportsData) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::DATA)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::DATA)); +} + +TEST_F(FunctionResourceCollections, AreaSupportsOperations) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::OPERATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::OPERATIONS)); +} + +TEST_F(FunctionResourceCollections, AreaSupportsConfigurations) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::CONFIGURATIONS)); + EXPECT_TRUE(caps.is_aggregated(ResourceCollection::CONFIGURATIONS)); +} + +TEST_F(FunctionResourceCollections, AreaSupportsBulkData) { + auto caps = EntityCapabilities::for_type(SovdEntityType::AREA); + EXPECT_TRUE(caps.supports_collection(ResourceCollection::BULK_DATA)); + // Bulk-data uses host-scoped filtering, not aggregation + EXPECT_FALSE(caps.is_aggregated(ResourceCollection::BULK_DATA)); +} + +// ============================================================================ +// AggregationService: get_child_app_ids +// ============================================================================ + +TEST_F(FunctionResourceCollections, FunctionChildAppIds) { + auto ids = service_->get_child_app_ids(SovdEntityType::FUNCTION, "sensing"); + ASSERT_EQ(ids.size(), 2); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "camera_driver") != ids.end()); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "lidar_proc") != ids.end()); +} + +TEST_F(FunctionResourceCollections, AreaChildAppIds) { + auto ids = service_->get_child_app_ids(SovdEntityType::AREA, "perception"); + ASSERT_EQ(ids.size(), 2); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "camera_driver") != ids.end()); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "lidar_proc") != ids.end()); +} + +TEST_F(FunctionResourceCollections, ComponentChildAppIds) { + auto ids = service_->get_child_app_ids(SovdEntityType::COMPONENT, "sensor_stack"); + ASSERT_EQ(ids.size(), 2); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "camera_driver") != ids.end()); + EXPECT_TRUE(std::find(ids.begin(), ids.end(), "lidar_proc") != ids.end()); +} + +TEST_F(FunctionResourceCollections, AppChildAppIdsReturnsSelf) { + auto ids = service_->get_child_app_ids(SovdEntityType::APP, "camera_driver"); + ASSERT_EQ(ids.size(), 1); + EXPECT_EQ(ids[0], "camera_driver"); +} + +TEST_F(FunctionResourceCollections, UnknownEntityChildAppIdsEmpty) { + auto ids = service_->get_child_app_ids(SovdEntityType::FUNCTION, "nonexistent"); + EXPECT_TRUE(ids.empty()); +} + +TEST_F(FunctionResourceCollections, UnknownTypeChildAppIdsEmpty) { + auto ids = service_->get_child_app_ids(SovdEntityType::UNKNOWN, "sensing"); + EXPECT_TRUE(ids.empty()); +} + +// ============================================================================ +// AggregationService: build_collection_x_medkit +// ============================================================================ + +TEST_F(FunctionResourceCollections, FunctionCollectionXMedkitHasAggregated) { + auto x_medkit = service_->build_collection_x_medkit(SovdEntityType::FUNCTION, "sensing"); + + EXPECT_TRUE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "function"); + ASSERT_TRUE(x_medkit.contains("aggregation_sources")); + EXPECT_EQ(x_medkit["aggregation_sources"].size(), 2); +} + +TEST_F(FunctionResourceCollections, AreaCollectionXMedkitHasAggregated) { + auto x_medkit = service_->build_collection_x_medkit(SovdEntityType::AREA, "perception"); + + EXPECT_TRUE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "area"); + ASSERT_TRUE(x_medkit.contains("aggregation_sources")); + EXPECT_EQ(x_medkit["aggregation_sources"].size(), 2); +} + +TEST_F(FunctionResourceCollections, ComponentCollectionXMedkitHasAggregated) { + auto x_medkit = service_->build_collection_x_medkit(SovdEntityType::COMPONENT, "sensor_stack"); + + EXPECT_TRUE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "component"); + ASSERT_TRUE(x_medkit.contains("aggregation_sources")); + EXPECT_EQ(x_medkit["aggregation_sources"].size(), 2); +} + +TEST_F(FunctionResourceCollections, AppCollectionXMedkitNotAggregated) { + auto x_medkit = service_->build_collection_x_medkit(SovdEntityType::APP, "camera_driver"); + + EXPECT_FALSE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "app"); + // Non-aggregated entities don't have aggregation_sources + EXPECT_FALSE(x_medkit.contains("aggregation_sources")); +} + +TEST_F(FunctionResourceCollections, ServerCollectionXMedkit) { + auto x_medkit = service_->build_collection_x_medkit(SovdEntityType::SERVER, "server"); + + EXPECT_TRUE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "server"); +} + +// ============================================================================ +// Function data aggregation via cache +// ============================================================================ + +TEST_F(FunctionResourceCollections, FunctionDataAggregatesFromHostedApps) { + auto result = cache_.get_function_data("sensing"); + + EXPECT_TRUE(result.is_aggregated); + EXPECT_EQ(result.aggregation_level, "function"); + EXPECT_GE(result.source_ids.size(), 2ul); + + // Should have topics from both camera_driver and lidar_proc + // camera_driver: /camera/image, /camera/info (pub), /camera/enable (sub) + // lidar_proc: /lidar/points (pub), /camera/image (sub - merged to 'both') + EXPECT_GE(result.topics.size(), 4ul); +} + +TEST_F(FunctionResourceCollections, FunctionDataMergesDirections) { + auto result = cache_.get_function_data("sensing"); + + // /camera/image is published by camera_driver and subscribed by lidar_proc + bool found_camera_image = false; + for (const auto & topic : result.topics) { + if (topic.name == "/camera/image") { + found_camera_image = true; + EXPECT_EQ(topic.direction, "both") << "/camera/image should be direction=both (pub+sub)"; + break; + } + } + EXPECT_TRUE(found_camera_image) << "/camera/image not found in function data"; +} + +TEST_F(FunctionResourceCollections, FunctionConfigurationsAggregatesFromHosts) { + auto result = cache_.get_function_configurations("sensing"); + + EXPECT_TRUE(result.is_aggregated); + EXPECT_EQ(result.aggregation_level, "function"); + EXPECT_GE(result.nodes.size(), 2ul); +} + +// ============================================================================ +// Area data aggregation via cache +// ============================================================================ + +TEST_F(FunctionResourceCollections, AreaDataAggregatesFromComponents) { + auto result = cache_.get_area_data("perception"); + + EXPECT_TRUE(result.is_aggregated); + EXPECT_EQ(result.aggregation_level, "area"); + EXPECT_GE(result.source_ids.size(), 1ul); + EXPECT_GE(result.topics.size(), 4ul); +} + +TEST_F(FunctionResourceCollections, AreaDataIncludesAllAppTopics) { + auto result = cache_.get_area_data("perception"); + + // Check that topics from both apps are present + bool found_lidar = false; + bool found_camera = false; + for (const auto & topic : result.topics) { + if (topic.name == "/lidar/points") { + found_lidar = true; + } + if (topic.name == "/camera/info") { + found_camera = true; + } + } + EXPECT_TRUE(found_lidar) << "/lidar/points not found in area data"; + EXPECT_TRUE(found_camera) << "/camera/info not found in area data"; +} + +// ============================================================================ +// get_entity_data auto-detects type and uses aggregation +// ============================================================================ + +TEST_F(FunctionResourceCollections, EntityDataAutoDetectsFunction) { + auto result = cache_.get_entity_data("sensing"); + EXPECT_EQ(result.aggregation_level, "function"); + EXPECT_TRUE(result.is_aggregated); +} + +TEST_F(FunctionResourceCollections, EntityDataAutoDetectsArea) { + auto result = cache_.get_entity_data("perception"); + EXPECT_EQ(result.aggregation_level, "area"); + EXPECT_TRUE(result.is_aggregated); +} + +TEST_F(FunctionResourceCollections, EntityDataAutoDetectsApp) { + auto result = cache_.get_entity_data("camera_driver"); + EXPECT_EQ(result.aggregation_level, "app"); + EXPECT_FALSE(result.is_aggregated); +} + +// ============================================================================ +// Operations aggregation for Function and Area +// ============================================================================ + +TEST_F(FunctionResourceCollections, FunctionOperationsAggregatesFromHosts) { + auto result = service_->get_operations(SovdEntityType::FUNCTION, "sensing"); + + EXPECT_TRUE(result.is_aggregated); + EXPECT_EQ(result.aggregation_level, "function"); + // Should have services from both camera_driver and lidar_proc + EXPECT_EQ(result.services.size(), 2); + EXPECT_GE(result.source_ids.size(), 2ul); +} + +TEST_F(FunctionResourceCollections, AreaOperationsAggregatesFromComponents) { + auto result = service_->get_operations(SovdEntityType::AREA, "perception"); + + EXPECT_TRUE(result.is_aggregated); + EXPECT_EQ(result.aggregation_level, "area"); + EXPECT_GE(result.services.size(), 2ul); +} + +TEST_F(FunctionResourceCollections, FunctionOperationsXMedkitCorrect) { + auto ops = service_->get_operations(SovdEntityType::FUNCTION, "sensing"); + auto x_medkit = AggregationService::build_x_medkit(ops); + + EXPECT_TRUE(x_medkit["aggregated"].get()); + EXPECT_EQ(x_medkit["aggregation_level"], "function"); + ASSERT_TRUE(x_medkit.contains("aggregation_sources")); + EXPECT_FALSE(x_medkit["aggregation_sources"].empty()); +} + +// ============================================================================ +// Edge cases +// ============================================================================ + +TEST_F(FunctionResourceCollections, EmptyFunctionReturnsEmptyAggregation) { + // Create a function with no hosts + std::vector empty_funcs = {make_function("empty_func", "Empty Function", {})}; + cache_.update_all(areas_, components_, apps_, empty_funcs); + + auto ops = service_->get_operations(SovdEntityType::FUNCTION, "empty_func"); + EXPECT_TRUE(ops.services.empty()); + EXPECT_TRUE(ops.actions.empty()); + + auto data = cache_.get_function_data("empty_func"); + EXPECT_TRUE(data.topics.empty()); + + auto child_ids = service_->get_child_app_ids(SovdEntityType::FUNCTION, "empty_func"); + EXPECT_TRUE(child_ids.empty()); +} + +TEST_F(FunctionResourceCollections, AreaWithNoComponentsReturnsEmpty) { + // Create an area with no components + std::vector solo_area = {make_area("empty_area", "Empty Area")}; + cache_.update_all(solo_area, {}, {}, {}); + + auto data = cache_.get_area_data("empty_area"); + EXPECT_TRUE(data.topics.empty()); + EXPECT_EQ(data.aggregation_level, "area"); + + auto child_ids = service_->get_child_app_ids(SovdEntityType::AREA, "empty_area"); + EXPECT_TRUE(child_ids.empty()); +} + +TEST_F(FunctionResourceCollections, NonexistentFunctionReturnsEmpty) { + auto ops = service_->get_operations(SovdEntityType::FUNCTION, "nonexistent"); + EXPECT_TRUE(ops.empty()); + + auto child_ids = service_->get_child_app_ids(SovdEntityType::FUNCTION, "nonexistent"); + EXPECT_TRUE(child_ids.empty()); +} + +int main(int argc, char ** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/ros2_medkit_gateway/test/test_handler_context.cpp b/src/ros2_medkit_gateway/test/test_handler_context.cpp index ed24f587f..b9deb6e0b 100644 --- a/src/ros2_medkit_gateway/test/test_handler_context.cpp +++ b/src/ros2_medkit_gateway/test/test_handler_context.cpp @@ -15,11 +15,27 @@ #include #include +#include #include - +#include +#include +#include +#include +#include +#include +#include +#include + +#include "ros2_medkit_gateway/aggregation/aggregation_manager.hpp" #include "ros2_medkit_gateway/config.hpp" +#include "ros2_medkit_gateway/discovery/models/app.hpp" +#include "ros2_medkit_gateway/discovery/models/area.hpp" +#include "ros2_medkit_gateway/discovery/models/component.hpp" +#include "ros2_medkit_gateway/discovery/models/function.hpp" +#include "ros2_medkit_gateway/gateway_node.hpp" #include "ros2_medkit_gateway/http/error_codes.hpp" #include "ros2_medkit_gateway/http/handlers/handler_context.hpp" +#include "ros2_medkit_gateway/models/thread_safe_entity_cache.hpp" using namespace ros2_medkit_gateway; using namespace ros2_medkit_gateway::handlers; @@ -365,6 +381,579 @@ TEST(CorsConfigBuilderTest, DefaultMaxAge) { EXPECT_EQ(config.max_age_seconds, 86400); // Default value } +// ============================================================================= +// Helper: reserve a free TCP port for test isolation +// ============================================================================= + +namespace { + +int reserve_local_port() { + int sock = socket(AF_INET, SOCK_STREAM, 0); + if (sock < 0) { + return 0; + } + sockaddr_in addr{}; + addr.sin_family = AF_INET; + addr.sin_addr.s_addr = htonl(INADDR_LOOPBACK); + addr.sin_port = 0; + if (bind(sock, reinterpret_cast(&addr), sizeof(addr)) != 0) { + close(sock); + return 0; + } + socklen_t addr_len = sizeof(addr); + if (getsockname(sock, reinterpret_cast(&addr), &addr_len) != 0) { + close(sock); + return 0; + } + int port = ntohs(addr.sin_port); + close(sock); + return port; +} + +/// Build a request with regex matches populated (simulates cpp-httplib routing) +httplib::Request make_request_with_path(const std::string & path) { + httplib::Request req; + req.method = "GET"; + req.path = path; + return req; +} + +} // namespace + +// ============================================================================= +// Shared rclcpp lifecycle for all fixtures that need GatewayNode +// ============================================================================= + +class RclcppEnvironment : public ::testing::Environment { + public: + void SetUp() override { + rclcpp::init(0, nullptr); + } + void TearDown() override { + rclcpp::shutdown(); + } +}; + +::testing::Environment * const rclcpp_env = ::testing::AddGlobalTestEnvironment(new RclcppEnvironment); + +// ============================================================================= +// HandlerContext forwarding tests (require GatewayNode + AggregationManager) +// ============================================================================= + +class HandlerContextForwardingTest : public ::testing::Test { + protected: + static inline std::shared_ptr suite_node_; + static inline int suite_server_port_ = 0; + + static void SetUpTestSuite() { + suite_server_port_ = reserve_local_port(); + ASSERT_NE(suite_server_port_, 0) << "Failed to reserve a port for test"; + + rclcpp::NodeOptions options; + options.append_parameter_override("server.port", suite_server_port_); + options.append_parameter_override("refresh_interval_ms", 60000); + suite_node_ = std::make_shared(options); + ASSERT_NE(suite_node_, nullptr); + } + + static void TearDownTestSuite() { + suite_node_.reset(); + } + + void SetUp() override { + ASSERT_NE(suite_node_, nullptr); + + // Seed the entity cache with a local component and a "remote" component. + // The "remote" component is just a normal component in the cache; the routing + // table in the AggregationManager is what marks it as remote. + Component local_comp; + local_comp.id = "local_ecu"; + local_comp.name = "Local ECU"; + local_comp.namespace_path = "/local"; + local_comp.fqn = "/local/local_ecu"; + + Component remote_comp; + remote_comp.id = "remote_sensor"; + remote_comp.name = "Remote Sensor"; + remote_comp.namespace_path = "/remote"; + remote_comp.fqn = "/remote/remote_sensor"; + + App remote_app; + remote_app.id = "remote_driver"; + remote_app.name = "Remote Driver"; + remote_app.component_id = "remote_sensor"; + + auto & cache = const_cast(suite_node_->get_thread_safe_cache()); + cache.update_all({}, {local_comp, remote_comp}, {remote_app}, {}); + + // Create AggregationManager with a static peer (unreachable - we only need routing) + AggregationConfig agg_config; + agg_config.enabled = true; + agg_config.timeout_ms = 200; + AggregationConfig::PeerConfig peer; + peer.url = "http://127.0.0.1:59999"; // Unreachable port + peer.name = "peer_subsystem"; + agg_config.peers.push_back(peer); + agg_mgr_ = std::make_unique(agg_config); + + // Set up routing table: remote_sensor and remote_driver are owned by peer_subsystem + std::unordered_map routing; + routing["remote_sensor"] = "peer_subsystem"; + routing["remote_driver"] = "peer_subsystem"; + agg_mgr_->update_routing_table(routing); + } + + void TearDown() override { + agg_mgr_.reset(); + } + + CorsConfig cors_{}; + AuthConfig auth_{}; + TlsConfig tls_{}; + std::unique_ptr agg_mgr_; +}; + +// When a remote entity is accessed and aggregation is enabled, validate_entity_for_route +// should forward the request to the owning peer and return nullopt. +TEST_F(HandlerContextForwardingTest, RemoteEntityWithAggregationForwardsAndReturnsNullopt) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto req = make_request_with_path("/api/v1/components/remote_sensor/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "remote_sensor"); + + // Should return kForwarded because the request was proxied to a peer + EXPECT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), ValidationOutcome::kForwarded); + + // The peer is unreachable, so forward_request sets 502 + EXPECT_EQ(res.status, 502); +} + +// When a remote app is accessed and aggregation is enabled, the same forwarding applies. +TEST_F(HandlerContextForwardingTest, RemoteAppWithAggregationForwardsAndReturnsNullopt) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto req = make_request_with_path("/api/v1/apps/remote_driver/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "remote_driver"); + + // Should return kForwarded because the request was proxied to a peer + EXPECT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), ValidationOutcome::kForwarded); + + // The peer is unreachable, so forward_request sets 502 + EXPECT_EQ(res.status, 502); +} + +// Verify that the correct peer name is used for forwarding by checking the +// 502 response body from the AggregationManager (it includes the peer name). +TEST_F(HandlerContextForwardingTest, ForwardingUsesCorrectPeerName) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto req = make_request_with_path("/api/v1/components/remote_sensor/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "remote_sensor"); + EXPECT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), ValidationOutcome::kForwarded); + + // The 502 response from forward_request to an unreachable peer contains + // information about the peer. Verify it mentions our peer name. + // (The peer exists in the manager but the host is unreachable, so we get + // a 502 from the PeerClient, not the "peer not known" 502.) + EXPECT_EQ(res.status, 502); +} + +// When aggregation_mgr_ is not set (no aggregation), remote entities from the +// routing table are never marked as remote (apply_routing is a no-op when +// aggregation_mgr_ is null). The entity is returned as a local entity. +TEST_F(HandlerContextForwardingTest, NoAggregationManagerReturnsEntityAsLocal) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + // Deliberately NOT setting aggregation manager + + auto req = make_request_with_path("/api/v1/components/remote_sensor/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "remote_sensor"); + + // Without aggregation manager, apply_routing never marks is_remote = true, + // so the entity is returned normally as if it were local + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->id, "remote_sensor"); + EXPECT_EQ(result->type, EntityType::COMPONENT); + EXPECT_FALSE(result->is_remote); + EXPECT_TRUE(result->peer_name.empty()); +} + +// Local entities should be returned normally even when aggregation is enabled. +TEST_F(HandlerContextForwardingTest, LocalEntityWithAggregationReturnsEntityInfo) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto req = make_request_with_path("/api/v1/components/local_ecu/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "local_ecu"); + + // Local entity is not in routing table, so it's not remote + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->id, "local_ecu"); + EXPECT_EQ(result->type, EntityType::COMPONENT); + EXPECT_FALSE(result->is_remote); + EXPECT_EQ(result->namespace_path, "/local"); + EXPECT_EQ(result->fqn, "/local/local_ecu"); +} + +// Entity not found at all should return 404 regardless of aggregation state. +TEST_F(HandlerContextForwardingTest, UnknownEntityReturns404WithAggregation) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto req = make_request_with_path("/api/v1/components/nonexistent/data"); + httplib::Response res; + + auto result = ctx.validate_entity_for_route(req, res, "nonexistent"); + + EXPECT_FALSE(result.has_value()); + EXPECT_EQ(result.error(), ValidationOutcome::kErrorSent); + EXPECT_EQ(res.status, 404); + + auto body = json::parse(res.body); + EXPECT_EQ(body["error_code"], ERR_ENTITY_NOT_FOUND); +} + +// Verify get_entity_info marks entity as remote when aggregation manager is set +// and entity is in the routing table. +TEST_F(HandlerContextForwardingTest, GetEntityInfoSetsRemoteFieldsWithAggregation) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + ctx.set_aggregation_manager(agg_mgr_.get()); + + auto info = ctx.get_entity_info("remote_sensor", SovdEntityType::COMPONENT); + + EXPECT_EQ(info.type, EntityType::COMPONENT); + EXPECT_TRUE(info.is_remote); + EXPECT_EQ(info.peer_name, "peer_subsystem"); + EXPECT_FALSE(info.peer_url.empty()); +} + +// Verify get_entity_info does NOT set remote fields when aggregation manager is null. +TEST_F(HandlerContextForwardingTest, GetEntityInfoNoRemoteWithoutAggregation) { + HandlerContext ctx(suite_node_.get(), cors_, auth_, tls_, nullptr); + // No aggregation manager set + + auto info = ctx.get_entity_info("remote_sensor", SovdEntityType::COMPONENT); + + EXPECT_EQ(info.type, EntityType::COMPONENT); + EXPECT_FALSE(info.is_remote); + EXPECT_TRUE(info.peer_name.empty()); + EXPECT_TRUE(info.peer_url.empty()); +} + +// ============================================================================= +// filter_internal_node_apps tests (no GatewayNode required) +// ============================================================================= + +TEST(FilterInternalNodeAppsTest, FiltersLocalInternalNodes) { + std::vector apps; + App normal; + normal.id = "temp_sensor"; + normal.name = "Temperature Sensor"; + apps.push_back(normal); + + App internal; + internal.id = "_ros2cli_daemon"; + internal.name = "ROS 2 CLI Daemon"; + apps.push_back(internal); + + App another_internal; + another_internal.id = "_launch_introspection"; + another_internal.name = "Launch Introspection"; + apps.push_back(another_internal); + + std::unordered_map routing; + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 2u); + ASSERT_EQ(apps.size(), 1u); + EXPECT_EQ(apps[0].id, "temp_sensor"); +} + +TEST(FilterInternalNodeAppsTest, PreservesAllNormalNodes) { + std::vector apps; + App a1; + a1.id = "temp_sensor"; + apps.push_back(a1); + + App a2; + a2.id = "rpm_sensor"; + apps.push_back(a2); + + App a3; + a3.id = "lidar_driver"; + apps.push_back(a3); + + std::unordered_map routing; + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 0u); + EXPECT_EQ(apps.size(), 3u); +} + +TEST(FilterInternalNodeAppsTest, FiltersPeerPrefixedInternalNodes) { + // Remote entity: peer_subsystem___ros2cli_daemon + // The routing table maps this to peer "peer_subsystem", so after stripping + // the prefix we get "_ros2cli_daemon" which starts with '_' -> filtered. + std::vector apps; + App remote_internal; + remote_internal.id = "peer_subsystem___ros2cli_daemon"; + remote_internal.name = "Remote Internal"; + apps.push_back(remote_internal); + + App remote_normal; + remote_normal.id = "peer_subsystem__lidar_driver"; + remote_normal.name = "Remote Lidar"; + apps.push_back(remote_normal); + + std::unordered_map routing; + routing["peer_subsystem___ros2cli_daemon"] = "peer_subsystem"; + routing["peer_subsystem__lidar_driver"] = "peer_subsystem"; + + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 1u); + ASSERT_EQ(apps.size(), 1u); + EXPECT_EQ(apps[0].id, "peer_subsystem__lidar_driver"); +} + +TEST(FilterInternalNodeAppsTest, DoesNotStripPrefixWithoutRoutingEntry) { + // An entity ID that looks like it has a peer prefix but has no routing entry. + // Without routing, we don't know the peer name, so we check the raw ID. + // "peer__normal_node" has no routing entry, raw ID doesn't start with '_' -> kept. + std::vector apps; + App ambiguous; + ambiguous.id = "peer__normal_node"; + ambiguous.name = "Ambiguous"; + apps.push_back(ambiguous); + + std::unordered_map routing; + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 0u); + ASSERT_EQ(apps.size(), 1u); + EXPECT_EQ(apps[0].id, "peer__normal_node"); +} + +TEST(FilterInternalNodeAppsTest, HandlesEmptyAppList) { + std::vector apps; + std::unordered_map routing; + + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 0u); + EXPECT_TRUE(apps.empty()); +} + +TEST(FilterInternalNodeAppsTest, MixedLocalAndRemoteInternalNodes) { + std::vector apps; + + App local_normal; + local_normal.id = "temp_sensor"; + apps.push_back(local_normal); + + App local_internal; + local_internal.id = "_daemon"; + apps.push_back(local_internal); + + App remote_normal; + remote_normal.id = "sub_b__actuator"; + remote_normal.name = "Remote Actuator"; + apps.push_back(remote_normal); + + App remote_internal; + remote_internal.id = "sub_b___parameter_bridge"; + remote_internal.name = "Remote Parameter Bridge"; + apps.push_back(remote_internal); + + std::unordered_map routing; + routing["sub_b__actuator"] = "sub_b"; + routing["sub_b___parameter_bridge"] = "sub_b"; + + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 2u); + ASSERT_EQ(apps.size(), 2u); + // Verify the surviving apps + std::set remaining_ids; + for (const auto & app : apps) { + remaining_ids.insert(app.id); + } + EXPECT_TRUE(remaining_ids.count("temp_sensor")); + EXPECT_TRUE(remaining_ids.count("sub_b__actuator")); +} + +TEST(FilterInternalNodeAppsTest, PeerPrefixMatchMustBeExact) { + // Entity ID: "my_peer__sensor" with routing mapping to peer "my_peer". + // After stripping "my_peer__", we get "sensor" which doesn't start with '_' -> kept. + // This verifies the prefix match is exact and doesn't over-strip. + std::vector apps; + App app; + app.id = "my_peer__sensor"; + apps.push_back(app); + + std::unordered_map routing; + routing["my_peer__sensor"] = "my_peer"; + + auto removed = filter_internal_node_apps(apps, routing); + + EXPECT_EQ(removed, 0u); + ASSERT_EQ(apps.size(), 1u); +} + +// ============================================================================= +// Area fault/log aggregation handler tests (via REST API) +// ============================================================================= + +class AreaAggregationTest : public ::testing::Test { + protected: + static inline std::shared_ptr suite_node_; + static inline int suite_server_port_ = 0; + + static void SetUpTestSuite() { + suite_server_port_ = reserve_local_port(); + ASSERT_NE(suite_server_port_, 0) << "Failed to reserve a port for test"; + + rclcpp::NodeOptions options; + options.append_parameter_override("server.port", suite_server_port_); + options.append_parameter_override("server.host", std::string("127.0.0.1")); + options.append_parameter_override("refresh_interval_ms", 60000); + suite_node_ = std::make_shared(options); + ASSERT_NE(suite_node_, nullptr); + } + + static void TearDownTestSuite() { + suite_node_.reset(); + } + + void SetUp() override { + ASSERT_NE(suite_node_, nullptr); + + // Seed the cache with Area -> Component -> App hierarchy. + // Area "powertrain" contains Component "engine_ecu" which has App "temp_sensor". + Area area; + area.id = "powertrain"; + area.name = "Powertrain"; + area.namespace_path = "/powertrain"; + + Component comp; + comp.id = "engine_ecu"; + comp.name = "Engine ECU"; + comp.area = "powertrain"; + comp.namespace_path = "/powertrain/engine"; + comp.fqn = "/powertrain/engine/engine_ecu"; + + App app1; + app1.id = "temp_sensor"; + app1.name = "Temperature Sensor"; + app1.component_id = "engine_ecu"; + app1.ros_binding = App::RosBinding{"/powertrain/engine/temp_sensor", "/powertrain/engine", ""}; + + App app2; + app2.id = "rpm_sensor"; + app2.name = "RPM Sensor"; + app2.component_id = "engine_ecu"; + app2.ros_binding = App::RosBinding{"/powertrain/engine/rpm_sensor", "/powertrain/engine", ""}; + + auto & cache = const_cast(suite_node_->get_thread_safe_cache()); + cache.update_all({area}, {comp}, {app1, app2}, {}); + + client_ = std::make_unique("127.0.0.1", suite_server_port_); + client_->set_connection_timeout(5); + client_->set_read_timeout(5); + } + + void TearDown() override { + client_.reset(); + } + + std::unique_ptr client_; +}; + +// Note: Area faults handler returns 503 when FaultManager service is unavailable +// (5s service timeout, too slow for unit tests under load). The area fault +// aggregation path is tested end-to-end in integration tests instead. + +// Area logs handler traverses area -> components -> apps -> FQNs chain and +// returns aggregated logs with x-medkit metadata. The LogManager is in-process +// so it works without external nodes (empty log buffer = empty items). +TEST_F(AreaAggregationTest, AreaLogsReturnsAggregatedResult) { + auto res = client_->Get("/api/v1/areas/powertrain/logs"); + ASSERT_NE(res, nullptr) << "HTTP request failed"; + EXPECT_EQ(res->status, 200); + + auto body = json::parse(res->body); + EXPECT_TRUE(body.contains("items")); + EXPECT_TRUE(body["items"].is_array()); + + // Verify x-medkit aggregation metadata is present + ASSERT_TRUE(body.contains("x-medkit")); + auto xmedkit = body["x-medkit"]; + EXPECT_EQ(xmedkit["entity_id"], "powertrain"); + EXPECT_EQ(xmedkit["aggregation_level"], "area"); + EXPECT_TRUE(xmedkit["aggregated"].get()); + EXPECT_EQ(xmedkit["component_count"], 1); + EXPECT_EQ(xmedkit["app_count"], 2); + + // Verify aggregation sources contain the app FQNs + ASSERT_TRUE(xmedkit.contains("aggregation_sources")); + auto sources = xmedkit["aggregation_sources"]; + EXPECT_EQ(sources.size(), 2); +} + +// Area with no components falls through to namespace prefix matching for logs. +// With no matching logs, returns empty items. +TEST_F(AreaAggregationTest, AreaLogsWithNoComponentsFallsThrough) { + auto & cache = const_cast(suite_node_->get_thread_safe_cache()); + Area empty_area; + empty_area.id = "empty_domain"; + empty_area.name = "Empty Domain"; + empty_area.namespace_path = "/empty"; + + Area pt_area; + pt_area.id = "powertrain"; + pt_area.name = "Powertrain"; + pt_area.namespace_path = "/powertrain"; + + Component comp; + comp.id = "engine_ecu"; + comp.name = "Engine ECU"; + comp.area = "powertrain"; + comp.namespace_path = "/powertrain/engine"; + comp.fqn = "/powertrain/engine/engine_ecu"; + + App app1; + app1.id = "temp_sensor"; + app1.name = "Temperature Sensor"; + app1.component_id = "engine_ecu"; + app1.ros_binding = App::RosBinding{"/powertrain/engine/temp_sensor", "/powertrain/engine", ""}; + + cache.update_all({pt_area, empty_area}, {comp}, {app1}, {}); + + auto res = client_->Get("/api/v1/areas/empty_domain/logs"); + ASSERT_NE(res, nullptr) << "HTTP request failed"; + // Falls through to namespace prefix matching (no components linked to area) + EXPECT_EQ(res->status, 200); + + auto body = json::parse(res->body); + EXPECT_TRUE(body.contains("items")); + EXPECT_TRUE(body["items"].is_array()); +} + int main(int argc, char ** argv) { testing::InitGoogleTest(&argc, argv); return RUN_ALL_TESTS(); diff --git a/src/ros2_medkit_gateway/test/test_host_info_provider.cpp b/src/ros2_medkit_gateway/test/test_host_info_provider.cpp new file mode 100644 index 000000000..e5f2f6856 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_host_info_provider.cpp @@ -0,0 +1,165 @@ +// 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. + +/** + * @file test_host_info_provider.cpp + * @brief Unit tests for HostInfoProvider - default Component from host system info + */ + +#include + +#include +#include + +#include "ros2_medkit_gateway/discovery/host_info_provider.hpp" + +using ros2_medkit_gateway::HostInfoProvider; +using ros2_medkit_gateway::json; + +// ============================================================================= +// HostInfoProvider Tests +// ============================================================================= + +class HostInfoProviderTest : public ::testing::Test { + protected: + HostInfoProvider provider_; +}; + +// @verifies REQ_INTEROP_003 +TEST_F(HostInfoProviderTest, creates_default_component) { + const auto & comp = provider_.get_default_component(); + + // id, name, source, type must be populated + EXPECT_FALSE(comp.id.empty()); + EXPECT_FALSE(comp.name.empty()); + EXPECT_EQ(comp.type, "Component"); + EXPECT_EQ(comp.source, "runtime"); +} + +TEST_F(HostInfoProviderTest, component_has_os_metadata) { + const auto & comp = provider_.get_default_component(); + json j = comp.to_json(); + + // Must have x-medkit.host with hostname, os, arch + ASSERT_TRUE(j.contains("x-medkit")); + ASSERT_TRUE(j["x-medkit"].contains("host")); + + const auto & host = j["x-medkit"]["host"]; + EXPECT_TRUE(host.contains("hostname")); + EXPECT_TRUE(host.contains("os")); + EXPECT_TRUE(host.contains("arch")); + + // Values should match provider accessors + EXPECT_EQ(host["hostname"].get(), provider_.hostname()); + EXPECT_EQ(host["os"].get(), provider_.os()); + EXPECT_EQ(host["arch"].get(), provider_.arch()); +} + +// @verifies REQ_INTEROP_003 +TEST_F(HostInfoProviderTest, sanitizes_hostname_to_valid_entity_id) { + const auto & comp = provider_.get_default_component(); + const std::string & id = comp.id; + + // All chars must be alphanumeric, underscore, or hyphen + for (char c : id) { + EXPECT_TRUE(std::isalnum(static_cast(c)) || c == '_' || c == '-') + << "Invalid character '" << c << "' in entity ID: " << id; + } + + // ID must be lowercase + for (char c : id) { + if (std::isalpha(static_cast(c))) { + EXPECT_TRUE(std::islower(static_cast(c))) + << "Uppercase character '" << c << "' in entity ID: " << id; + } + } + + // ID must not exceed 256 characters + EXPECT_LE(id.size(), 256u); +} + +// ============================================================================= +// sanitize_entity_id() Tests +// ============================================================================= + +TEST(SanitizeEntityIdTest, converts_dots_to_underscores) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("my.host.name"), "my_host_name"); +} + +TEST(SanitizeEntityIdTest, converts_spaces_to_underscores) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("my host"), "my_host"); +} + +TEST(SanitizeEntityIdTest, converts_to_lowercase) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("MyHost"), "myhost"); +} + +TEST(SanitizeEntityIdTest, preserves_hyphens) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("my-host"), "my-host"); +} + +TEST(SanitizeEntityIdTest, strips_invalid_characters) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("my@host!name#1"), "myhostname1"); +} + +TEST(SanitizeEntityIdTest, handles_mixed_input) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("Dev.Machine 01-A!"), "dev_machine_01-a"); +} + +TEST(SanitizeEntityIdTest, truncates_to_256_chars) { + std::string long_input(300, 'a'); + std::string result = HostInfoProvider::sanitize_entity_id(long_input); + EXPECT_EQ(result.size(), 256u); +} + +TEST(SanitizeEntityIdTest, handles_empty_input) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id(""), ""); +} + +TEST(SanitizeEntityIdTest, handles_all_invalid_chars) { + EXPECT_EQ(HostInfoProvider::sanitize_entity_id("@#$%^&*()"), ""); +} + +// ============================================================================= +// Host info accessor tests +// ============================================================================= + +TEST_F(HostInfoProviderTest, hostname_is_not_empty) { + EXPECT_FALSE(provider_.hostname().empty()); +} + +TEST_F(HostInfoProviderTest, os_is_not_empty) { + EXPECT_FALSE(provider_.os().empty()); +} + +TEST_F(HostInfoProviderTest, arch_is_not_empty) { + EXPECT_FALSE(provider_.arch().empty()); +} + +TEST_F(HostInfoProviderTest, description_contains_os_and_arch) { + const auto & comp = provider_.get_default_component(); + // Description should be "OS on arch" + EXPECT_NE(comp.description.find(provider_.os()), std::string::npos); + EXPECT_NE(comp.description.find(provider_.arch()), std::string::npos); + EXPECT_NE(comp.description.find(" on "), std::string::npos); +} + +// ============================================================================= +// Main +// ============================================================================= + +int main(int argc, char ** argv) { + testing::InitGoogleTest(&argc, argv); + return RUN_ALL_TESTS(); +} diff --git a/src/ros2_medkit_gateway/test/test_mdns_discovery.cpp b/src/ros2_medkit_gateway/test/test_mdns_discovery.cpp new file mode 100644 index 000000000..628b4deff --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_mdns_discovery.cpp @@ -0,0 +1,279 @@ +// 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 "ros2_medkit_gateway/aggregation/mdns_discovery.hpp" + +using namespace ros2_medkit_gateway; + +// ============================================================================= +// Config / construction tests (no network sockets opened) +// ============================================================================= + +TEST(MdnsDiscovery, default_config_values) { + MdnsDiscovery::Config config; + + EXPECT_FALSE(config.announce); + EXPECT_FALSE(config.discover); + EXPECT_EQ(config.service, "_medkit._tcp.local"); + EXPECT_EQ(config.port, 8080); + EXPECT_TRUE(config.name.empty()); + EXPECT_FALSE(config.on_error); // No error callback by default +} + +TEST(MdnsDiscovery, respects_announce_flag) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; // Don't try to open sockets + + MdnsDiscovery discovery(config); + + // Start with both flags off - no threads should launch + bool found_called = false; + bool removed_called = false; + + discovery.start( + [&](const std::string & /*url*/, const std::string & /*name*/) { + found_called = true; + }, + [&](const std::string & /*name*/) { + removed_called = true; + }); + + // Neither thread should be running since both flags are off + EXPECT_FALSE(discovery.is_announcing()); + EXPECT_FALSE(discovery.is_discovering()); + + discovery.stop(); + + EXPECT_FALSE(found_called); + EXPECT_FALSE(removed_called); +} + +TEST(MdnsDiscovery, respects_discover_flag) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + MdnsDiscovery discovery(config); + + discovery.start([](const std::string &, const std::string &) {}, [](const std::string &) {}); + + EXPECT_FALSE(discovery.is_discovering()); + EXPECT_FALSE(discovery.is_announcing()); + + discovery.stop(); +} + +TEST(MdnsDiscovery, stop_is_idempotent) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + MdnsDiscovery discovery(config); + + // Start then stop multiple times - should not crash or hang + discovery.start([](const std::string &, const std::string &) {}, [](const std::string &) {}); + + discovery.stop(); + discovery.stop(); // Second stop should be a no-op + discovery.stop(); // Third stop should also be safe + + EXPECT_FALSE(discovery.is_announcing()); + EXPECT_FALSE(discovery.is_discovering()); +} + +TEST(MdnsDiscovery, destructor_stops_cleanly) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + { + MdnsDiscovery discovery(config); + discovery.start([](const std::string &, const std::string &) {}, [](const std::string &) {}); + // Destructor should call stop() without hanging + } + + // If we get here without hanging, the test passes + SUCCEED(); +} + +TEST(MdnsDiscovery, custom_config_values) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + config.service = "_custom._tcp.local"; + config.port = 9090; + config.name = "test-gateway"; + + MdnsDiscovery discovery(config); + + // Construction with custom values should succeed + EXPECT_FALSE(discovery.is_announcing()); + EXPECT_FALSE(discovery.is_discovering()); +} + +TEST(MdnsDiscovery, start_without_callbacks_is_safe) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + MdnsDiscovery discovery(config); + + // Passing nullptr-equivalent callbacks should not crash + discovery.start(nullptr, nullptr); + discovery.stop(); +} + +TEST(MdnsDiscovery, not_running_before_start) { + MdnsDiscovery::Config config; + config.announce = true; + config.discover = true; + + MdnsDiscovery discovery(config); + + // Before start(), nothing should be running + EXPECT_FALSE(discovery.is_announcing()); + EXPECT_FALSE(discovery.is_discovering()); +} + +TEST(MdnsDiscovery, error_callback_is_stored_in_config) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + bool error_called = false; + config.on_error = [&error_called](const std::string & /*msg*/) { + error_called = true; + }; + + MdnsDiscovery discovery(config); + + // The callback is stored but not invoked when no sockets are opened + EXPECT_FALSE(error_called); +} + +// ============================================================================= +// Error callback wiring tests +// ============================================================================= + +TEST(MdnsDiscovery, error_callback_not_invoked_without_sockets) { + // Verify the on_error callback is not spuriously triggered when announce/discover are off + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + bool error_received = false; + std::string error_message; + config.on_error = [&](const std::string & msg) { + error_received = true; + error_message = msg; + }; + + MdnsDiscovery mdns(config); + + // Start and stop with both flags off - no socket operations + mdns.start([](const std::string &, const std::string &) {}, [](const std::string &) {}); + mdns.stop(); + + // Error callback should NOT have been invoked since no network activity occurred + EXPECT_FALSE(error_received); + EXPECT_TRUE(error_message.empty()); +} + +TEST(MdnsDiscovery, default_announce_and_discover_are_false) { + MdnsDiscovery::Config config; + EXPECT_FALSE(config.announce); + EXPECT_FALSE(config.discover); +} + +TEST(MdnsDiscovery, default_service_type) { + MdnsDiscovery::Config config; + EXPECT_EQ(config.service, "_medkit._tcp.local"); +} + +TEST(MdnsDiscovery, default_port_is_8080) { + MdnsDiscovery::Config config; + EXPECT_EQ(config.port, 8080); +} + +TEST(MdnsDiscovery, default_name_is_empty) { + MdnsDiscovery::Config config; + EXPECT_TRUE(config.name.empty()); +} + +TEST(MdnsDiscovery, default_on_error_is_null) { + MdnsDiscovery::Config config; + EXPECT_FALSE(config.on_error); +} + +TEST(MdnsDiscovery, start_stop_lifecycle_with_callbacks) { + MdnsDiscovery::Config config; + config.announce = false; + config.discover = false; + + MdnsDiscovery discovery(config); + + bool found_called = false; + bool removed_called = false; + + discovery.start( + [&](const std::string & /*url*/, const std::string & /*name*/) { + found_called = true; + }, + [&](const std::string & /*name*/) { + removed_called = true; + }); + + // With both flags off, callbacks should never fire + EXPECT_FALSE(found_called); + EXPECT_FALSE(removed_called); + EXPECT_FALSE(discovery.is_announcing()); + EXPECT_FALSE(discovery.is_discovering()); + + discovery.stop(); + + EXPECT_FALSE(found_called); + EXPECT_FALSE(removed_called); +} + +// ============================================================================= +// instance_name() tests +// ============================================================================= + +TEST(MdnsDiscovery, instance_name_returns_explicit_name) { + MdnsDiscovery::Config config; + config.name = "my-gateway"; + MdnsDiscovery discovery(config); + EXPECT_EQ(discovery.instance_name(), "my-gateway"); +} + +TEST(MdnsDiscovery, instance_name_defaults_to_hostname_when_empty) { + MdnsDiscovery::Config config; + // config.name is empty by default - constructor resolves to gethostname() + MdnsDiscovery discovery(config); + EXPECT_FALSE(discovery.instance_name().empty()); +} + +TEST(MdnsDiscovery, instance_name_preserves_set_value_across_lifecycle) { + MdnsDiscovery::Config config; + config.name = "perception-ecu"; + MdnsDiscovery discovery(config); + EXPECT_EQ(discovery.instance_name(), "perception-ecu"); + discovery.stop(); + EXPECT_EQ(discovery.instance_name(), "perception-ecu"); +} diff --git a/src/ros2_medkit_gateway/test/test_merge_pipeline.cpp b/src/ros2_medkit_gateway/test/test_merge_pipeline.cpp index c2cdaa286..882a93cb5 100644 --- a/src/ros2_medkit_gateway/test/test_merge_pipeline.cpp +++ b/src/ros2_medkit_gateway/test/test_merge_pipeline.cpp @@ -532,8 +532,6 @@ TEST_F(MergePipelineTest, PluginFunctionEnrichesExistingFunction) { // @verifies REQ_INTEROP_003 TEST(GapFillConfigTest, DefaultAllowsAll) { GapFillConfig config; - EXPECT_TRUE(config.allow_heuristic_areas); - EXPECT_TRUE(config.allow_heuristic_components); EXPECT_TRUE(config.allow_heuristic_apps); EXPECT_FALSE(config.allow_heuristic_functions); } @@ -1537,18 +1535,17 @@ TEST(PluginLayerTest, ValidationKeepsAllValidEntities) { EXPECT_EQ(output.apps[2].id, "gamma_3"); } -// --- create_synthetic_areas=false produces no areas (#261) --- +// --- Runtime layer produces no areas or components --- -TEST_F(MergePipelineTest, CreateSyntheticAreasFalseProducesNoAreas) { - // Simulate a runtime-only layer that provides apps but no areas, - // as would happen with create_synthetic_areas=false on RuntimeDiscoveryStrategy. - // Verify the pipeline works correctly with empty areas and exposes only apps. +TEST_F(MergePipelineTest, RuntimeLayerProducesNoAreasOrComponents) { + // Runtime discovery never creates Areas or Components. + // Verify the pipeline works correctly with only apps from runtime layer. App runtime_app = make_app("sensor_node", ""); runtime_app.source = "heuristic"; LayerOutput runtime_out; - // No areas in output - mirrors RuntimeDiscoveryStrategy with create_synthetic_areas=false + // No areas or components - runtime discovery only provides apps and functions runtime_out.apps.push_back(runtime_app); pipeline_.add_layer(std::make_unique( @@ -1873,3 +1870,229 @@ TEST_F(MergePipelineTest, SuppressHeuristicAppsInCoveredNamespace) { } } } + +// ============================================================================= +// unmanifested_nodes: ignore policy tests +// ============================================================================= + +TEST_F(MergePipelineTest, IgnorePolicyHidesOrphanApps) { + // Manifest app with ros_binding + App manifest_app = make_app("nav_app", "compute_unit"); + manifest_app.source = "manifest"; + App::RosBinding binding; + binding.node_name = "nav_controller"; + binding.namespace_pattern = "/navigation"; + binding.topic_namespace = ""; + manifest_app.ros_binding = binding; + + // Runtime nodes: one matches manifest, one is orphan + App runtime_nav; + runtime_nav.id = "nav_controller"; + runtime_nav.name = "nav_controller"; + runtime_nav.source = "heuristic"; + runtime_nav.is_online = true; + runtime_nav.bound_fqn = "/navigation/nav_controller"; + + App runtime_orphan; + runtime_orphan.id = "_param_client_node"; + runtime_orphan.name = "_param_client_node"; + runtime_orphan.source = "heuristic"; + runtime_orphan.is_online = true; + runtime_orphan.bound_fqn = "/_param_client_node"; + + LayerOutput manifest_out; + manifest_out.apps.push_back(manifest_app); + + LayerOutput runtime_out; + runtime_out.apps.push_back(runtime_nav); + runtime_out.apps.push_back(runtime_orphan); + + pipeline_.add_layer(std::make_unique( + "manifest", manifest_out, + std::unordered_map{{FieldGroup::IDENTITY, MergePolicy::AUTHORITATIVE}})); + pipeline_.add_layer(std::make_unique( + "runtime", runtime_out, + std::unordered_map{{FieldGroup::STATUS, MergePolicy::AUTHORITATIVE}})); + + ManifestConfig config; + config.unmanifested_nodes = ManifestConfig::UnmanifestedNodePolicy::IGNORE; + pipeline_.set_linker(std::make_unique(nullptr), config); + + auto result = pipeline_.execute(); + + // Only manifest app should remain - orphan _param_client_node should be hidden + ASSERT_EQ(result.apps.size(), 1u); + EXPECT_EQ(result.apps[0].id, "nav_app"); + EXPECT_EQ(result.apps[0].source, "manifest"); +} + +TEST_F(MergePipelineTest, IgnorePolicySuppressesOrphanNamespaceComponents) { + // Manifest component in /navigation + Component manifest_comp = make_component("compute_unit", "", "/navigation"); + manifest_comp.source = "manifest"; + + // Runtime synthetic component in /perception (orphan namespace) + Component runtime_comp = make_component("perception", "", "/perception"); + runtime_comp.source = "heuristic"; + + // Manifest app linked to /navigation + App manifest_app = make_app("nav_app", "compute_unit"); + manifest_app.source = "manifest"; + App::RosBinding binding; + binding.node_name = "nav_controller"; + binding.namespace_pattern = "/navigation"; + binding.topic_namespace = ""; + manifest_app.ros_binding = binding; + + // Runtime nodes + App runtime_nav; + runtime_nav.id = "nav_controller"; + runtime_nav.name = "nav_controller"; + runtime_nav.source = "heuristic"; + runtime_nav.is_online = true; + runtime_nav.bound_fqn = "/navigation/nav_controller"; + + App runtime_perception; + runtime_perception.id = "camera_node"; + runtime_perception.name = "camera_node"; + runtime_perception.source = "heuristic"; + runtime_perception.is_online = true; + runtime_perception.bound_fqn = "/perception/camera_node"; + + LayerOutput manifest_out; + manifest_out.components.push_back(manifest_comp); + manifest_out.apps.push_back(manifest_app); + + LayerOutput runtime_out; + runtime_out.components.push_back(runtime_comp); + runtime_out.apps.push_back(runtime_nav); + runtime_out.apps.push_back(runtime_perception); + + pipeline_.add_layer(std::make_unique( + "manifest", manifest_out, + std::unordered_map{{FieldGroup::IDENTITY, MergePolicy::AUTHORITATIVE}})); + pipeline_.add_layer(std::make_unique( + "runtime", runtime_out, + std::unordered_map{{FieldGroup::STATUS, MergePolicy::AUTHORITATIVE}})); + + ManifestConfig config; + config.unmanifested_nodes = ManifestConfig::UnmanifestedNodePolicy::IGNORE; + pipeline_.set_linker(std::make_unique(nullptr), config); + + auto result = pipeline_.execute(); + + // Only manifest component should remain - /perception suppressed + ASSERT_EQ(result.components.size(), 1u); + EXPECT_EQ(result.components[0].id, "compute_unit"); + + // Only manifest app - orphan camera_node hidden + ASSERT_EQ(result.apps.size(), 1u); + EXPECT_EQ(result.apps[0].id, "nav_app"); +} + +TEST_F(MergePipelineTest, WarnPolicyKeepsOrphanApps) { + // Same setup as IgnorePolicyHidesOrphanApps but with WARN policy + App manifest_app = make_app("nav_app", "compute_unit"); + manifest_app.source = "manifest"; + App::RosBinding binding; + binding.node_name = "nav_controller"; + binding.namespace_pattern = "/navigation"; + binding.topic_namespace = ""; + manifest_app.ros_binding = binding; + + App runtime_nav; + runtime_nav.id = "nav_controller"; + runtime_nav.name = "nav_controller"; + runtime_nav.source = "heuristic"; + runtime_nav.is_online = true; + runtime_nav.bound_fqn = "/navigation/nav_controller"; + + App runtime_orphan; + runtime_orphan.id = "orphan_node"; + runtime_orphan.name = "orphan_node"; + runtime_orphan.source = "heuristic"; + runtime_orphan.is_online = true; + runtime_orphan.bound_fqn = "/orphan_node"; + + LayerOutput manifest_out; + manifest_out.apps.push_back(manifest_app); + + LayerOutput runtime_out; + runtime_out.apps.push_back(runtime_nav); + runtime_out.apps.push_back(runtime_orphan); + + pipeline_.add_layer(std::make_unique( + "manifest", manifest_out, + std::unordered_map{{FieldGroup::IDENTITY, MergePolicy::AUTHORITATIVE}})); + pipeline_.add_layer(std::make_unique( + "runtime", runtime_out, + std::unordered_map{{FieldGroup::STATUS, MergePolicy::AUTHORITATIVE}})); + + ManifestConfig config; + config.unmanifested_nodes = ManifestConfig::UnmanifestedNodePolicy::WARN; + pipeline_.set_linker(std::make_unique(nullptr), config); + + auto result = pipeline_.execute(); + + // WARN policy should keep orphan apps (gap-fill behavior) + EXPECT_GE(result.apps.size(), 2u); + bool found_orphan = false; + for (const auto & app : result.apps) { + if (app.id == "orphan_node") { + found_orphan = true; + } + } + EXPECT_TRUE(found_orphan) << "WARN policy should preserve orphan apps"; +} + +TEST_F(MergePipelineTest, IgnorePolicyFiltersHeuristicAppsWithoutBoundFqn) { + // Manifest app with ros_binding + App manifest_app = make_app("nav_app", "compute_unit"); + manifest_app.source = "manifest"; + App::RosBinding binding; + binding.node_name = "nav_controller"; + binding.namespace_pattern = "/navigation"; + binding.topic_namespace = ""; + manifest_app.ros_binding = binding; + + // Runtime node matching manifest + App runtime_nav; + runtime_nav.id = "nav_controller"; + runtime_nav.name = "nav_controller"; + runtime_nav.source = "heuristic"; + runtime_nav.is_online = true; + runtime_nav.bound_fqn = "/navigation/nav_controller"; + + // Heuristic app WITHOUT bound_fqn (e.g., _param_client_node from topic inspection) + App heuristic_no_fqn; + heuristic_no_fqn.id = "_param_client_node"; + heuristic_no_fqn.name = "_param_client_node"; + heuristic_no_fqn.source = "heuristic"; + heuristic_no_fqn.is_online = true; + // No bound_fqn set - this is the bug scenario + + LayerOutput manifest_out; + manifest_out.apps.push_back(manifest_app); + + LayerOutput runtime_out; + runtime_out.apps.push_back(runtime_nav); + runtime_out.apps.push_back(heuristic_no_fqn); + + pipeline_.add_layer(std::make_unique( + "manifest", manifest_out, + std::unordered_map{{FieldGroup::IDENTITY, MergePolicy::AUTHORITATIVE}})); + pipeline_.add_layer(std::make_unique( + "runtime", runtime_out, + std::unordered_map{{FieldGroup::STATUS, MergePolicy::AUTHORITATIVE}})); + + ManifestConfig config; + config.unmanifested_nodes = ManifestConfig::UnmanifestedNodePolicy::IGNORE; + pipeline_.set_linker(std::make_unique(nullptr), config); + + auto result = pipeline_.execute(); + + // Only manifest app should remain - heuristic app without bound_fqn should be hidden + ASSERT_EQ(result.apps.size(), 1u); + EXPECT_EQ(result.apps[0].id, "nav_app"); + EXPECT_EQ(result.apps[0].source, "manifest"); +} diff --git a/src/ros2_medkit_gateway/test/test_network_utils.cpp b/src/ros2_medkit_gateway/test/test_network_utils.cpp new file mode 100644 index 000000000..13f01cf5e --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_network_utils.cpp @@ -0,0 +1,178 @@ +// 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 "ros2_medkit_gateway/aggregation/network_utils.hpp" + +using namespace ros2_medkit_gateway; + +// ============================================================================= +// parse_url_host_port tests +// ============================================================================= + +TEST(NetworkUtils, parse_ipv4_url) { + auto [host, port] = parse_url_host_port("http://192.168.1.5:8080"); + EXPECT_EQ(host, "192.168.1.5"); + EXPECT_EQ(port, 8080); +} + +TEST(NetworkUtils, parse_ipv4_url_with_path) { + auto [host, port] = parse_url_host_port("http://10.0.0.1:9090/api/v1"); + EXPECT_EQ(host, "10.0.0.1"); + EXPECT_EQ(port, 9090); +} + +TEST(NetworkUtils, parse_https_url) { + auto [host, port] = parse_url_host_port("https://192.168.1.5:8443"); + EXPECT_EQ(host, "192.168.1.5"); + EXPECT_EQ(port, 8443); +} + +TEST(NetworkUtils, parse_ipv6_url) { + auto [host, port] = parse_url_host_port("http://[::1]:8080"); + EXPECT_EQ(host, "::1"); + EXPECT_EQ(port, 8080); +} + +TEST(NetworkUtils, parse_ipv6_full_address) { + auto [host, port] = parse_url_host_port("http://[fe80::1%25eth0]:8080"); + EXPECT_EQ(host, "fe80::1%25eth0"); + EXPECT_EQ(port, 8080); +} + +TEST(NetworkUtils, parse_ipv6_with_path) { + auto [host, port] = parse_url_host_port("http://[::1]:9090/api/v1"); + EXPECT_EQ(host, "::1"); + EXPECT_EQ(port, 9090); +} + +TEST(NetworkUtils, parse_localhost) { + auto [host, port] = parse_url_host_port("http://127.0.0.1:8080"); + EXPECT_EQ(host, "127.0.0.1"); + EXPECT_EQ(port, 8080); +} + +TEST(NetworkUtils, parse_no_scheme_returns_invalid) { + auto [host, port] = parse_url_host_port("192.168.1.5:8080"); + EXPECT_EQ(host, ""); + EXPECT_EQ(port, -1); +} + +TEST(NetworkUtils, parse_empty_string_returns_invalid) { + auto [host, port] = parse_url_host_port(""); + EXPECT_EQ(host, ""); + EXPECT_EQ(port, -1); +} + +TEST(NetworkUtils, parse_no_port_returns_host_only) { + auto [host, port] = parse_url_host_port("http://example.com"); + EXPECT_EQ(host, "example.com"); + EXPECT_EQ(port, -1); +} + +TEST(NetworkUtils, parse_hostname_with_port) { + auto [host, port] = parse_url_host_port("http://my-gateway:8080"); + EXPECT_EQ(host, "my-gateway"); + EXPECT_EQ(port, 8080); +} + +TEST(NetworkUtils, parse_invalid_port_returns_negative) { + auto [host, port] = parse_url_host_port("http://192.168.1.5:notaport"); + EXPECT_EQ(host, "192.168.1.5"); + EXPECT_EQ(port, -1); +} + +TEST(NetworkUtils, parse_ipv6_missing_closing_bracket) { + auto [host, port] = parse_url_host_port("http://[::1:8080"); + EXPECT_EQ(host, ""); + EXPECT_EQ(port, -1); +} + +TEST(NetworkUtils, parse_ipv6_no_port) { + auto [host, port] = parse_url_host_port("http://[::1]"); + EXPECT_EQ(host, "::1"); + EXPECT_EQ(port, -1); +} + +// ============================================================================= +// collect_local_addresses tests +// ============================================================================= + +TEST(NetworkUtils, local_addresses_include_loopback) { + auto addrs = collect_local_addresses(); + EXPECT_TRUE(addrs.count("127.0.0.1") > 0) << "Should always include IPv4 loopback"; + EXPECT_TRUE(addrs.count("::1") > 0) << "Should always include IPv6 loopback"; +} + +TEST(NetworkUtils, local_addresses_not_empty) { + auto addrs = collect_local_addresses(); + // At minimum: 127.0.0.1 and ::1 + EXPECT_GE(addrs.size(), 2u); +} + +// ============================================================================= +// Self-discovery filter scenario tests +// ============================================================================= + +TEST(NetworkUtils, spoofed_mdns_response_detected_ipv4) { + // Simulate: attacker sends mDNS response with different name but local IP:port + auto local_addrs = collect_local_addresses(); + int self_port = 8080; + std::string spoofed_url = "http://127.0.0.1:8080"; + std::string spoofed_name = "attacker-gateway"; + + auto [peer_host, peer_port] = parse_url_host_port(spoofed_url); + + // The IP-based filter should catch this + bool is_self = (peer_port == self_port && local_addrs.count(peer_host) > 0); + EXPECT_TRUE(is_self) << "Should detect loopback address as self"; +} + +TEST(NetworkUtils, spoofed_mdns_response_detected_ipv6) { + auto local_addrs = collect_local_addresses(); + int self_port = 8080; + std::string spoofed_url = "http://[::1]:8080"; + + auto [peer_host, peer_port] = parse_url_host_port(spoofed_url); + + bool is_self = (peer_port == self_port && local_addrs.count(peer_host) > 0); + EXPECT_TRUE(is_self) << "Should detect IPv6 loopback as self"; +} + +TEST(NetworkUtils, legitimate_peer_not_rejected) { + auto local_addrs = collect_local_addresses(); + int self_port = 8080; + // Use an IP that is almost certainly not a local interface + std::string peer_url = "http://198.51.100.42:8080"; + + auto [peer_host, peer_port] = parse_url_host_port(peer_url); + + bool is_self = (peer_port == self_port && local_addrs.count(peer_host) > 0); + EXPECT_FALSE(is_self) << "Should not reject non-local addresses (TEST-NET-2 198.51.100.0/24)"; +} + +TEST(NetworkUtils, different_port_not_rejected) { + auto local_addrs = collect_local_addresses(); + int self_port = 8080; + // Same local IP but different port - this is a different service, not self + std::string peer_url = "http://127.0.0.1:9090"; + + auto [peer_host, peer_port] = parse_url_host_port(peer_url); + + bool is_self = (peer_port == self_port && local_addrs.count(peer_host) > 0); + EXPECT_FALSE(is_self) << "Should not reject local address with different port"; +} diff --git a/src/ros2_medkit_gateway/test/test_peer_client.cpp b/src/ros2_medkit_gateway/test/test_peer_client.cpp new file mode 100644 index 000000000..510dd7275 --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_peer_client.cpp @@ -0,0 +1,575 @@ +// 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 "ros2_medkit_gateway/aggregation/peer_client.hpp" +#include "ros2_medkit_gateway/http/handlers/handler_context.hpp" + +using namespace ros2_medkit_gateway; + +// Use a port that nothing listens on for connection-refused tests +constexpr int DEAD_PORT = 59999; +static const std::string DEAD_URL = "http://127.0.0.1:" + std::to_string(DEAD_PORT); + +// ============================================================================= +// Construction and basic accessor tests +// ============================================================================= + +TEST(PeerClient, constructs_with_url_and_name) { + PeerClient client("http://localhost:8081", "subsystem_b", 500); + + EXPECT_EQ(client.url(), "http://localhost:8081"); + EXPECT_EQ(client.name(), "subsystem_b"); +} + +TEST(PeerClient, initially_not_healthy) { + PeerClient client(DEAD_URL, "dead_peer", 500); + + EXPECT_FALSE(client.is_healthy()); +} + +// ============================================================================= +// Health check tests (connection refused path) +// ============================================================================= + +TEST(PeerClient, health_check_marks_unhealthy_on_connection_refused) { + PeerClient client(DEAD_URL, "dead_peer", 200); + + // Should not throw, just mark unhealthy + client.check_health(); + EXPECT_FALSE(client.is_healthy()); +} + +// ============================================================================= +// Forward request tests (connection failure path) +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(PeerClient, forward_sets_502_on_connection_error) { + PeerClient client(DEAD_URL, "dead_peer", 200); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/components"; + httplib::Response res; + + client.forward_request(req, res); + + EXPECT_EQ(res.status, 502); + + auto body = nlohmann::json::parse(res.body, nullptr, false); + ASSERT_FALSE(body.is_discarded()); + EXPECT_EQ(body["error_code"], "vendor-error"); + EXPECT_EQ(body["vendor_code"], "x-medkit-peer-unavailable"); + EXPECT_TRUE(body["message"].get().find("dead_peer") != std::string::npos); +} + +// ============================================================================= +// forward_and_get_json tests (connection failure path) +// ============================================================================= + +TEST(PeerClient, forward_and_get_json_returns_error_on_connection_refused) { + PeerClient client(DEAD_URL, "dead_peer", 200); + + auto result = client.forward_and_get_json("GET", "/api/v1/health"); + + ASSERT_FALSE(result.has_value()); + EXPECT_TRUE(result.error().find("dead_peer") != std::string::npos); + EXPECT_TRUE(result.error().find("Failed to connect") != std::string::npos); +} + +// ============================================================================= +// fetch_entities tests (connection failure path) +// ============================================================================= + +// @verifies REQ_INTEROP_003 +TEST(PeerClient, fetch_entities_returns_error_on_connection_refused) { + PeerClient client(DEAD_URL, "dead_peer", 200); + + auto result = client.fetch_entities(); + + ASSERT_FALSE(result.has_value()); + EXPECT_TRUE(result.error().find("dead_peer") != std::string::npos); +} + +// ============================================================================= +// EntityInfo remote field tests +// ============================================================================= + +TEST(EntityInfoRemote, defaults_to_local) { + EntityInfo info; + EXPECT_FALSE(info.is_remote); + EXPECT_TRUE(info.peer_url.empty()); + EXPECT_TRUE(info.peer_name.empty()); +} + +TEST(EntityInfoRemote, can_be_marked_remote) { + EntityInfo info; + info.is_remote = true; + info.peer_url = "http://192.168.1.10:8081"; + info.peer_name = "subsystem_a"; + + EXPECT_TRUE(info.is_remote); + EXPECT_EQ(info.peer_url, "http://192.168.1.10:8081"); + EXPECT_EQ(info.peer_name, "subsystem_a"); +} + +// ============================================================================= +// Mock server happy-path tests (local httplib::Server) +// ============================================================================= + +#include + +TEST(PeerClientHappyPath, health_check_marks_healthy) { + httplib::Server svr; + svr.Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"status":"healthy"})", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + EXPECT_FALSE(client.is_healthy()); + + client.check_health(); + EXPECT_TRUE(client.is_healthy()); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, health_check_unhealthy_on_500) { + httplib::Server svr; + svr.Get("/api/v1/health", [](const httplib::Request &, httplib::Response & res) { + res.status = 500; + res.set_content(R"({"status":"error"})", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + client.check_health(); + EXPECT_FALSE(client.is_healthy()); + + svr.stop(); + t.join(); +} + +// @verifies REQ_INTEROP_003 +TEST(PeerClientHappyPath, fetch_entities_parses_collections) { + httplib::Server svr; + svr.Get("/api/v1/areas", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[{"id":"zone_a","name":"Zone A"}]})", "application/json"); + }); + svr.Get("/api/v1/components", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[{"id":"ecu_1","name":"ECU 1"}]})", "application/json"); + }); + svr.Get("/api/v1/apps", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[{"id":"nav","name":"Navigation"}]})", "application/json"); + }); + svr.Get("/api/v1/functions", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[]})", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + auto result = client.fetch_entities(); + + ASSERT_TRUE(result.has_value()); + EXPECT_EQ(result->areas.size(), 1u); + EXPECT_EQ(result->areas[0].id, "zone_a"); + EXPECT_EQ(result->areas[0].name, "Zone A"); + EXPECT_EQ(result->areas[0].source, "peer:test_peer"); + EXPECT_EQ(result->components.size(), 1u); + EXPECT_EQ(result->components[0].id, "ecu_1"); + EXPECT_EQ(result->components[0].source, "peer:test_peer"); + EXPECT_EQ(result->apps.size(), 1u); + EXPECT_EQ(result->apps[0].id, "nav"); + EXPECT_EQ(result->apps[0].source, "peer:test_peer"); + EXPECT_EQ(result->functions.size(), 0u); + + svr.stop(); + t.join(); +} + +// @verifies REQ_INTEROP_003 +TEST(PeerClientHappyPath, fetch_entities_parses_relationship_fields) { + httplib::Server svr; + + // Areas: top-level only in list (subareas filtered) + svr.Get("/api/v1/areas", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"vehicle","name":"Vehicle"} + ]})", + "application/json"); + }); + // Subareas endpoint for "vehicle" + svr.Get(R"(/api/v1/areas/vehicle/subareas)", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"sensors","name":"Sensors","x-medkit":{"namespace":"/sensors"}} + ]})", + "application/json"); + }); + + // Components: top-level only in list (subcomponents filtered) + svr.Get("/api/v1/components", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"robot-alpha","name":"Robot Alpha","x-medkit":{"source":"manifest"}} + ]})", + "application/json"); + }); + // Component detail (for relationship data) + svr.Get(R"(/api/v1/components/robot-alpha)", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"id":"robot-alpha","name":"Robot Alpha","x-medkit":{"source":"manifest"}})", + "application/json"); + }); + // Subcomponents endpoint for "robot-alpha" + svr.Get(R"(/api/v1/components/robot-alpha/subcomponents)", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"perception-ecu","name":"Perception ECU", + "x-medkit":{"source":"manifest","namespace":"/perception"}} + ]})", + "application/json"); + }); + // Subcomponent detail + svr.Get(R"(/api/v1/components/perception-ecu)", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"id":"perception-ecu","name":"Perception ECU", + "x-medkit":{"parentComponentId":"robot-alpha","dependsOn":["compute-unit"], + "source":"manifest","namespace":"/perception"}})", + "application/json"); + }); + // No subcomponents for perception-ecu (leaf) + svr.Get(R"(/api/v1/components/perception-ecu/subcomponents)", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"items":[]})", "application/json"); + }); + + svr.Get("/api/v1/apps", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"lidar-driver","name":"Lidar Driver", + "x-medkit":{"component_id":"perception-ecu","source":"manifest","is_online":true}} + ]})", + "application/json"); + }); + svr.Get("/api/v1/functions", [](const httplib::Request &, httplib::Response & res) { + res.set_content( + R"({"items":[ + {"id":"autonomous-navigation","name":"Autonomous Navigation", + "x-medkit":{"hosts":["lidar-driver","path-planner"],"source":"manifest"}} + ]})", + "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "peer_ecu", 5000); + auto result = client.fetch_entities(); + + ASSERT_TRUE(result.has_value()); + + // Areas: top-level + subareas fetched + ASSERT_EQ(result->areas.size(), 2u); + EXPECT_EQ(result->areas[0].id, "vehicle"); + EXPECT_EQ(result->areas[1].id, "sensors"); + + // Components: top-level + subcomponents fetched (robot-alpha + perception-ecu) + ASSERT_EQ(result->components.size(), 2u); + EXPECT_EQ(result->components[0].id, "robot-alpha"); + EXPECT_EQ(result->components[1].id, "perception-ecu"); + EXPECT_EQ(result->components[1].parent_component_id, "robot-alpha"); + ASSERT_EQ(result->components[1].depends_on.size(), 1u); + EXPECT_EQ(result->components[1].depends_on[0], "compute-unit"); + + // App: component_id parsed + ASSERT_EQ(result->apps.size(), 1u); + EXPECT_EQ(result->apps[0].component_id, "perception-ecu"); + + // Function: hosts parsed + ASSERT_EQ(result->functions.size(), 1u); + EXPECT_EQ(result->functions[0].id, "autonomous-navigation"); + ASSERT_EQ(result->functions[0].hosts.size(), 2u); + EXPECT_EQ(result->functions[0].hosts[0], "lidar-driver"); + EXPECT_EQ(result->functions[0].hosts[1], "path-planner"); + + svr.stop(); + t.join(); +} + +// @verifies REQ_INTEROP_018 +TEST(PeerClientHappyPath, forward_request_proxies_response_with_auth) { + httplib::Server svr; + svr.Get(R"(/api/v1/apps/nav/data)", [](const httplib::Request & req, httplib::Response & res) { + nlohmann::json body; + body["forwarded"] = true; + body["has_auth"] = req.has_header("Authorization"); + if (req.has_header("Authorization")) { + body["auth"] = req.get_header_value("Authorization"); + } + res.set_content(body.dump(), "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + // forward_auth=true: Authorization header should be forwarded + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000, true); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/apps/nav/data"; + req.set_header("Authorization", "Bearer test-token"); + + httplib::Response res; + client.forward_request(req, res); + + EXPECT_EQ(res.status, 200); + auto body = nlohmann::json::parse(res.body); + EXPECT_TRUE(body["forwarded"].get()); + EXPECT_TRUE(body["has_auth"].get()); + EXPECT_EQ(body["auth"].get(), "Bearer test-token"); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_request_does_not_forward_auth_by_default) { + httplib::Server svr; + svr.Get(R"(/api/v1/apps/nav/data)", [](const httplib::Request & req, httplib::Response & res) { + nlohmann::json body; + body["forwarded"] = true; + body["has_auth"] = req.has_header("Authorization"); + res.set_content(body.dump(), "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + // forward_auth=false (default): Authorization header should NOT be forwarded + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/apps/nav/data"; + req.set_header("Authorization", "Bearer test-token"); + + httplib::Response res; + client.forward_request(req, res); + + EXPECT_EQ(res.status, 200); + auto body = nlohmann::json::parse(res.body); + EXPECT_TRUE(body["forwarded"].get()); + EXPECT_FALSE(body["has_auth"].get()); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_filters_response_headers) { + httplib::Server svr; + svr.Get("/api/v1/test", [](const httplib::Request &, httplib::Response & res) { + res.set_header("ETag", "\"abc123\""); + res.set_header("Cache-Control", "no-cache"); + res.set_header("Set-Cookie", "evil=1"); + res.set_header("Access-Control-Allow-Origin", "*"); + res.set_header("X-Medkit-Custom", "allowed"); + res.set_content("{}", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + + httplib::Request req; + req.method = "GET"; + req.path = "/api/v1/test"; + + httplib::Response res; + client.forward_request(req, res); + + EXPECT_EQ(res.status, 200); + // Safe headers should be present + EXPECT_TRUE(res.has_header("ETag")); + EXPECT_TRUE(res.has_header("Cache-Control")); + // x-medkit vendor headers should be forwarded + EXPECT_TRUE(res.has_header("X-Medkit-Custom")); + // Dangerous headers should be filtered + EXPECT_FALSE(res.has_header("Set-Cookie")); + EXPECT_FALSE(res.has_header("Access-Control-Allow-Origin")); + + svr.stop(); + t.join(); +} + +// @verifies REQ_INTEROP_018 +TEST(PeerClientHappyPath, forward_and_get_json_returns_parsed_json) { + httplib::Server svr; + svr.Get("/api/v1/components/ecu/data", [](const httplib::Request &, httplib::Response & res) { + res.set_content(R"({"temperature":42.5,"status":"ok"})", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + auto result = client.forward_and_get_json("GET", "/api/v1/components/ecu/data"); + + ASSERT_TRUE(result.has_value()); + EXPECT_DOUBLE_EQ(result->at("temperature").get(), 42.5); + EXPECT_EQ(result->at("status").get(), "ok"); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_and_get_json_with_auth_header_when_enabled) { + httplib::Server svr; + svr.Get("/api/v1/health", [](const httplib::Request & req, httplib::Response & res) { + nlohmann::json body; + body["has_auth"] = req.has_header("Authorization"); + if (req.has_header("Authorization")) { + body["auth_value"] = req.get_header_value("Authorization"); + } + res.set_content(body.dump(), "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + // forward_auth=true: auth header should be sent + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000, true); + auto result = client.forward_and_get_json("GET", "/api/v1/health", "Bearer my-jwt"); + + ASSERT_TRUE(result.has_value()); + EXPECT_TRUE(result->at("has_auth").get()); + EXPECT_EQ(result->at("auth_value").get(), "Bearer my-jwt"); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_and_get_json_does_not_forward_auth_by_default) { + httplib::Server svr; + svr.Get("/api/v1/health", [](const httplib::Request & req, httplib::Response & res) { + nlohmann::json body; + body["has_auth"] = req.has_header("Authorization"); + res.set_content(body.dump(), "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + // forward_auth=false (default): auth header should NOT be sent + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + auto result = client.forward_and_get_json("GET", "/api/v1/health", "Bearer my-jwt"); + + ASSERT_TRUE(result.has_value()); + EXPECT_FALSE(result->at("has_auth").get()); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_and_get_json_error_on_non_2xx) { + httplib::Server svr; + svr.Get("/api/v1/missing", [](const httplib::Request &, httplib::Response & res) { + res.status = 404; + res.set_content(R"({"error":"not found"})", "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + auto result = client.forward_and_get_json("GET", "/api/v1/missing"); + + ASSERT_FALSE(result.has_value()); + EXPECT_TRUE(result.error().find("404") != std::string::npos); + EXPECT_TRUE(result.error().find("test_peer") != std::string::npos); + + svr.stop(); + t.join(); +} + +TEST(PeerClientHappyPath, forward_post_request) { + httplib::Server svr; + svr.Post("/api/v1/apps/nav/operations/restart", [](const httplib::Request & req, httplib::Response & res) { + auto body = nlohmann::json::parse(req.body, nullptr, false); + nlohmann::json response; + response["executed"] = true; + response["received_body"] = body; + res.set_content(response.dump(), "application/json"); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread t([&]() { + svr.listen_after_bind(); + }); + + PeerClient client("http://127.0.0.1:" + std::to_string(port), "test_peer", 5000); + + httplib::Request req; + req.method = "POST"; + req.path = "/api/v1/apps/nav/operations/restart"; + req.body = R"({"force":true})"; + req.set_header("Content-Type", "application/json"); + + httplib::Response res; + client.forward_request(req, res); + + EXPECT_EQ(res.status, 200); + auto body = nlohmann::json::parse(res.body); + EXPECT_TRUE(body["executed"].get()); + EXPECT_TRUE(body["received_body"]["force"].get()); + + svr.stop(); + t.join(); +} diff --git a/src/ros2_medkit_gateway/test/test_runtime_discovery.cpp b/src/ros2_medkit_gateway/test/test_runtime_discovery.cpp new file mode 100644 index 000000000..62b0795ce --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_runtime_discovery.cpp @@ -0,0 +1,232 @@ +// 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 "ros2_medkit_gateway/discovery/runtime_discovery.hpp" + +using ros2_medkit_gateway::discovery::RuntimeDiscoveryStrategy; + +// ============================================================================= +// Global rclcpp lifecycle - runs once for ALL test suites in this binary +// ============================================================================= + +class RclcppEnvironment : public ::testing::Environment { + public: + void SetUp() override { + rclcpp::init(0, nullptr); + } + void TearDown() override { + rclcpp::shutdown(); + } +}; + +::testing::Environment * const rclcpp_env = ::testing::AddGlobalTestEnvironment(new RclcppEnvironment); + +// ============================================================================= +// RuntimeDiscoveryStrategy - Function from namespace tests +// ============================================================================= + +class RuntimeDiscoveryTest : public ::testing::Test { + protected: + void SetUp() override { + // Create node in a namespace to test namespace grouping + rclcpp::NodeOptions options; + node_ = std::make_shared("test_node", "/test_ns", options); + strategy_ = std::make_unique(node_.get()); + } + + void TearDown() override { + strategy_.reset(); + node_.reset(); + } + + std::shared_ptr node_; + std::unique_ptr strategy_; +}; + +// ----------------------------------------------------------------------------- +// discover_areas() - always returns empty (Areas come from manifest only) +// ----------------------------------------------------------------------------- + +TEST_F(RuntimeDiscoveryTest, DiscoverAreas_AlwaysReturnsEmpty) { + auto areas = strategy_->discover_areas(); + EXPECT_TRUE(areas.empty()) << "Areas should always be empty - Areas come from manifest only"; +} + +// ----------------------------------------------------------------------------- +// discover_components() - always returns empty (Components come from +// HostInfoProvider or manifest) +// ----------------------------------------------------------------------------- + +TEST_F(RuntimeDiscoveryTest, DiscoverComponents_AlwaysReturnsEmpty) { + auto components = strategy_->discover_components(); + EXPECT_TRUE(components.empty()) + << "Components should always be empty - Components come from HostInfoProvider or manifest"; +} + +// ----------------------------------------------------------------------------- +// discover_functions() - namespace grouping +// ----------------------------------------------------------------------------- + +// @verifies REQ_INTEROP_003 +TEST_F(RuntimeDiscoveryTest, DiscoverFunctions_DefaultCreatesFromNamespaces) { + // Default config has create_functions_from_namespaces=true + auto functions = strategy_->discover_functions(); + + // Should find at least "test_ns" function from our node's namespace + bool found_test_ns = false; + for (const auto & func : functions) { + if (func.id == "test_ns") { + found_test_ns = true; + EXPECT_EQ(func.name, "test_ns"); + EXPECT_EQ(func.source, "runtime"); + EXPECT_FALSE(func.hosts.empty()) << "Function should have at least one host app"; + // The host should be our test node + bool found_test_node = false; + for (const auto & host_id : func.hosts) { + if (host_id == "test_node") { + found_test_node = true; + } + } + EXPECT_TRUE(found_test_node) << "Function hosts should include 'test_node'"; + } + } + EXPECT_TRUE(found_test_ns) << "Should discover 'test_ns' function from namespace grouping"; +} + +TEST_F(RuntimeDiscoveryTest, DiscoverFunctions_ReturnsEmptyWhenDisabled) { + RuntimeDiscoveryStrategy::RuntimeConfig config; + config.create_functions_from_namespaces = false; + strategy_->set_config(config); + + auto functions = strategy_->discover_functions(); + EXPECT_TRUE(functions.empty()) << "Functions should be empty when create_functions_from_namespaces=false"; +} + +TEST_F(RuntimeDiscoveryTest, DiscoverFunctions_HostsPointToAppIds) { + auto functions = strategy_->discover_functions(); + auto apps = strategy_->discover_apps(); + + // All host IDs in functions should correspond to actual app IDs + for (const auto & func : functions) { + for (const auto & host_id : func.hosts) { + bool found = false; + for (const auto & app : apps) { + if (app.id == host_id) { + found = true; + break; + } + } + EXPECT_TRUE(found) << "Function '" << func.id << "' host '" << host_id << "' should be a valid app ID"; + } + } +} + +TEST_F(RuntimeDiscoveryTest, DiscoverFunctions_OnlyCreatesNonEmptyFunctions) { + // All discovered functions should have at least one host + auto functions = strategy_->discover_functions(); + for (const auto & func : functions) { + EXPECT_FALSE(func.hosts.empty()) << "Function '" << func.id << "' should have at least one host app"; + } +} + +// ----------------------------------------------------------------------------- +// RuntimeConfig defaults +// ----------------------------------------------------------------------------- + +TEST_F(RuntimeDiscoveryTest, DefaultConfig_HasCorrectDefaults) { + RuntimeDiscoveryStrategy::RuntimeConfig config; + EXPECT_TRUE(config.create_functions_from_namespaces) << "create_functions_from_namespaces should default to true"; +} + +// ============================================================================= +// Tests with multiple namespaces +// ============================================================================= + +class RuntimeDiscoveryMultiNsTest : public ::testing::Test { + protected: + void SetUp() override { + // Create nodes in different namespaces + node_sensors_ = std::make_shared("camera", "/sensors"); + node_nav_ = std::make_shared("planner", "/navigation"); + // Create discovery node (the one that queries the graph) + node_discovery_ = std::make_shared("discovery_node"); + strategy_ = std::make_unique(node_discovery_.get()); + } + + void TearDown() override { + strategy_.reset(); + node_discovery_.reset(); + node_nav_.reset(); + node_sensors_.reset(); + } + + std::shared_ptr node_sensors_; + std::shared_ptr node_nav_; + std::shared_ptr node_discovery_; + std::unique_ptr strategy_; +}; + +// @verifies REQ_INTEROP_003 +TEST_F(RuntimeDiscoveryMultiNsTest, DiscoverFunctions_GroupsByNamespace) { + auto functions = strategy_->discover_functions(); + + bool found_sensors = false; + bool found_navigation = false; + bool found_root = false; + + for (const auto & func : functions) { + if (func.id == "sensors") { + found_sensors = true; + EXPECT_EQ(func.source, "runtime"); + // "camera" node should be in sensors + auto it = std::find(func.hosts.begin(), func.hosts.end(), "camera"); + EXPECT_NE(it, func.hosts.end()) << "sensors function should host 'camera' app"; + } + if (func.id == "navigation") { + found_navigation = true; + EXPECT_EQ(func.source, "runtime"); + // "planner" node should be in navigation + auto it = std::find(func.hosts.begin(), func.hosts.end(), "planner"); + EXPECT_NE(it, func.hosts.end()) << "navigation function should host 'planner' app"; + } + if (func.id == "root") { + found_root = true; + // "discovery_node" is in root namespace + auto it = std::find(func.hosts.begin(), func.hosts.end(), "discovery_node"); + EXPECT_NE(it, func.hosts.end()) << "root function should host 'discovery_node' app"; + } + } + + EXPECT_TRUE(found_sensors) << "Should create 'sensors' function from /sensors namespace"; + EXPECT_TRUE(found_navigation) << "Should create 'navigation' function from /navigation namespace"; + EXPECT_TRUE(found_root) << "Should create 'root' function from root namespace nodes"; +} + +TEST_F(RuntimeDiscoveryMultiNsTest, DiscoverAreas_AlwaysEmpty) { + auto areas = strategy_->discover_areas(); + EXPECT_TRUE(areas.empty()) << "Areas should always be empty - Areas come from manifest only"; +} + +TEST_F(RuntimeDiscoveryMultiNsTest, DiscoverComponents_AlwaysEmpty) { + auto components = strategy_->discover_components(); + EXPECT_TRUE(components.empty()) + << "Components should always be empty - Components come from HostInfoProvider or manifest"; +} diff --git a/src/ros2_medkit_gateway/test/test_stream_proxy.cpp b/src/ros2_medkit_gateway/test/test_stream_proxy.cpp new file mode 100644 index 000000000..5671178ae --- /dev/null +++ b/src/ros2_medkit_gateway/test/test_stream_proxy.cpp @@ -0,0 +1,398 @@ +// 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 "ros2_medkit_gateway/aggregation/stream_proxy.hpp" + +using namespace ros2_medkit_gateway; + +// ============================================================================= +// Construction and lifecycle tests +// ============================================================================= + +TEST(StreamProxy, sse_proxy_can_be_constructed) { + SSEStreamProxy proxy("http://localhost:8081", "/api/v1/faults/sse", "peer_a"); + // Should not throw or crash +} + +TEST(StreamProxy, initially_not_connected) { + SSEStreamProxy proxy("http://localhost:8081", "/api/v1/faults/sse", "peer_a"); + EXPECT_FALSE(proxy.is_connected()); +} + +TEST(StreamProxy, close_is_idempotent) { + SSEStreamProxy proxy("http://localhost:8081", "/api/v1/faults/sse", "peer_a"); + // Calling close() multiple times without open() should not crash + proxy.close(); + proxy.close(); + proxy.close(); + EXPECT_FALSE(proxy.is_connected()); +} + +// ============================================================================= +// SSE parsing tests (pure function, no networking required) +// ============================================================================= + +TEST(StreamProxy, parse_sse_single_event) { + std::string raw = "data: {\"temperature\": 42}\n\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_a"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].data, "{\"temperature\": 42}"); + EXPECT_EQ(events[0].event_type, "message"); // Default SSE type + EXPECT_EQ(events[0].peer_name, "peer_a"); + EXPECT_TRUE(events[0].id.empty()); +} + +TEST(StreamProxy, parse_sse_multiple_events) { + std::string raw = + "data: {\"temp\": 42}\n" + "\n" + "data: {\"temp\": 43}\n" + "\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_b"); + + ASSERT_EQ(events.size(), 2u); + EXPECT_EQ(events[0].data, "{\"temp\": 42}"); + EXPECT_EQ(events[0].peer_name, "peer_b"); + EXPECT_EQ(events[1].data, "{\"temp\": 43}"); + EXPECT_EQ(events[1].peer_name, "peer_b"); +} + +TEST(StreamProxy, parse_sse_multiline_data) { + std::string raw = + "data: line one\n" + "data: line two\n" + "data: line three\n" + "\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_c"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].data, "line one\nline two\nline three"); + EXPECT_EQ(events[0].event_type, "message"); +} + +TEST(StreamProxy, parse_sse_with_event_type) { + std::string raw = + "event: data_update\n" + "data: {\"sensor\": \"imu\"}\n" + "id: 123\n" + "\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_d"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].event_type, "data_update"); + EXPECT_EQ(events[0].data, "{\"sensor\": \"imu\"}"); + EXPECT_EQ(events[0].id, "123"); + EXPECT_EQ(events[0].peer_name, "peer_d"); +} + +TEST(StreamProxy, parse_sse_empty_input) { + auto events = SSEStreamProxy::parse_sse_data("", "peer_e"); + EXPECT_TRUE(events.empty()); +} + +// ============================================================================= +// Additional SSE parsing edge cases +// ============================================================================= + +TEST(StreamProxy, parse_sse_with_comments) { + std::string raw = + ": this is a comment\n" + "data: hello\n" + "\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_f"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].data, "hello"); +} + +TEST(StreamProxy, parse_sse_event_without_trailing_blank_line) { + // An event at end of stream without a trailing blank line should still parse + std::string raw = "event: fault_added\ndata: {\"fault_id\": \"F001\"}\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_g"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].event_type, "fault_added"); + EXPECT_EQ(events[0].data, "{\"fault_id\": \"F001\"}"); +} + +TEST(StreamProxy, parse_sse_mixed_events_with_and_without_types) { + std::string raw = + "event: fault_added\n" + "data: {\"fault_id\": \"F001\"}\n" + "\n" + "data: {\"heartbeat\": true}\n" + "\n" + "event: fault_cleared\n" + "data: {\"fault_id\": \"F001\"}\n" + "id: 42\n" + "\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_h"); + + ASSERT_EQ(events.size(), 3u); + + EXPECT_EQ(events[0].event_type, "fault_added"); + EXPECT_EQ(events[0].data, "{\"fault_id\": \"F001\"}"); + EXPECT_TRUE(events[0].id.empty()); + + EXPECT_EQ(events[1].event_type, "message"); + EXPECT_EQ(events[1].data, "{\"heartbeat\": true}"); + + EXPECT_EQ(events[2].event_type, "fault_cleared"); + EXPECT_EQ(events[2].data, "{\"fault_id\": \"F001\"}"); + EXPECT_EQ(events[2].id, "42"); +} + +TEST(StreamProxy, parse_sse_with_crlf_line_endings) { + std::string raw = "event: update\r\ndata: payload\r\n\r\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_i"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].event_type, "update"); + EXPECT_EQ(events[0].data, "payload"); +} + +TEST(StreamProxy, parse_sse_data_with_colons_in_value) { + // The SSE spec says only the first colon splits field from value + std::string raw = "data: http://example.com:8080/path\n\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_j"); + + ASSERT_EQ(events.size(), 1u); + EXPECT_EQ(events[0].data, "http://example.com:8080/path"); +} + +TEST(StreamProxy, parse_sse_blank_lines_only) { + // Only blank lines with no data should produce no events + std::string raw = "\n\n\n\n"; + + auto events = SSEStreamProxy::parse_sse_data(raw, "peer_k"); + EXPECT_TRUE(events.empty()); +} + +TEST(StreamProxy, on_event_can_set_callback) { + SSEStreamProxy proxy("http://localhost:8081", "/api/v1/faults/sse", "peer_l"); + bool called = false; + proxy.on_event([&called](const StreamEvent &) { + called = true; + }); + // Callback is set but not invoked without open() + EXPECT_FALSE(called); +} + +// ============================================================================= +// Mock server integration tests (local httplib::Server) +// ============================================================================= + +#include +#include +#include +#include + +namespace { + +/// Helper to wait for httplib::Server to be ready for connections +void wait_for_server(httplib::Server & svr, int timeout_ms = 5000) { + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(timeout_ms); + while (!svr.is_running() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(5)); + } +} + +} // namespace + +TEST(SSEStreamProxyIntegration, receives_events_from_mock_server) { + httplib::Server svr; + + std::atomic stop_stream{false}; + + // Chunked content provider simulating a real SSE stream: + // sends events with pauses between them, then waits until stopped. + svr.Get("/events", [&stop_stream](const httplib::Request &, httplib::Response & res) { + res.set_chunked_content_provider("text/event-stream", [&stop_stream](size_t offset, httplib::DataSink & sink) { + if (offset == 0) { + std::string event1 = + "event: test\n" + "data: {\"value\":42}\n" + "\n"; + sink.write(event1.data(), event1.size()); + return true; + } + // Second call - send the second event + std::string data_so_far; + if (offset > 0 && offset < 200) { + std::string event2 = + "event: update\n" + "data: {\"value\":43}\n" + "\n"; + sink.write(event2.data(), event2.size()); + return true; + } + // Keep connection open until test signals stop + if (stop_stream.load()) { + sink.done(); + return false; + } + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + return true; + }); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread server_thread([&svr]() { + svr.listen_after_bind(); + }); + wait_for_server(svr); + + SSEStreamProxy proxy("http://127.0.0.1:" + std::to_string(port), "/events", "test_peer"); + + std::vector received; + std::mutex mtx; + std::condition_variable cv; + + proxy.on_event([&](const StreamEvent & event) { + std::lock_guard lock(mtx); + received.push_back(event); + cv.notify_one(); + }); + + proxy.open(); + + // Wait for at least two events (with timeout) + { + std::unique_lock lock(mtx); + cv.wait_for(lock, std::chrono::seconds(5), [&]() { + return received.size() >= 2u; + }); + } + + stop_stream.store(true); + proxy.close(); + svr.stop(); + server_thread.join(); + + ASSERT_GE(received.size(), 2u); + EXPECT_EQ(received[0].event_type, "test"); + EXPECT_EQ(received[0].data, "{\"value\":42}"); + EXPECT_EQ(received[0].peer_name, "test_peer"); + EXPECT_EQ(received[1].event_type, "update"); + EXPECT_EQ(received[1].data, "{\"value\":43}"); + EXPECT_EQ(received[1].peer_name, "test_peer"); +} + +TEST(SSEStreamProxyIntegration, close_terminates_reader_thread) { + httplib::Server svr; + + // Chunked provider that streams indefinitely until client disconnects + svr.Get("/events", [](const httplib::Request &, httplib::Response & res) { + res.set_chunked_content_provider("text/event-stream", [](size_t /*offset*/, httplib::DataSink & sink) { + std::string event = "data: heartbeat\n\n"; + sink.write(event.data(), event.size()); + // Sleep to simulate a long-lived stream + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + return true; + }); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread server_thread([&svr]() { + svr.listen_after_bind(); + }); + wait_for_server(svr); + + SSEStreamProxy proxy("http://127.0.0.1:" + std::to_string(port), "/events", "long_stream_peer"); + + std::atomic event_count{0}; + proxy.on_event([&](const StreamEvent &) { + event_count.fetch_add(1); + }); + + proxy.open(); + + // Wait for at least one event to confirm the connection is live + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (event_count.load() == 0 && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + + // Even if we got zero events (e.g. timing), close() must not hang + auto close_start = std::chrono::steady_clock::now(); + proxy.close(); + auto close_duration = std::chrono::steady_clock::now() - close_start; + + // Reader thread should have joined within a reasonable time + EXPECT_LT(close_duration, std::chrono::seconds(5)); + EXPECT_FALSE(proxy.is_connected()); + + svr.stop(); + server_thread.join(); + + // Verify that at least one event was received (confirms streaming worked) + EXPECT_GT(event_count.load(), 0); +} + +TEST(SSEStreamProxyIntegration, buffer_overflow_disconnects) { + httplib::Server svr; + + // Server sends >1MB without any event boundary (\n\n) + svr.Get("/events", [](const httplib::Request &, httplib::Response & res) { + res.set_chunked_content_provider("text/event-stream", [](size_t /*offset*/, httplib::DataSink & sink) { + // Send 8KB chunks of data without a double-newline boundary + std::string chunk(8192, 'x'); + sink.write(chunk.data(), chunk.size()); + return true; + }); + }); + + int port = svr.bind_to_any_port("127.0.0.1"); + std::thread server_thread([&svr]() { + svr.listen_after_bind(); + }); + wait_for_server(svr); + + SSEStreamProxy proxy("http://127.0.0.1:" + std::to_string(port), "/events", "overflow_peer"); + + std::atomic event_count{0}; + proxy.on_event([&](const StreamEvent &) { + event_count.fetch_add(1); + }); + + proxy.open(); + + // Wait for the proxy to disconnect due to buffer overflow + auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(5); + while (proxy.is_connected() && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(50)); + } + + proxy.close(); + svr.stop(); + server_thread.join(); + + // No valid events should have been delivered since there were no boundaries + EXPECT_EQ(event_count.load(), 0); +} diff --git a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp index 628c41e50..585513624 100644 --- a/src/ros2_medkit_gateway/test/test_trigger_manager.cpp +++ b/src/ros2_medkit_gateway/test/test_trigger_manager.cpp @@ -750,8 +750,11 @@ TEST_F(TriggerManagerTest, ExpiredTrigger_FiresOnRemovedCallback) { EXPECT_TRUE(manager_->is_active(created->id)); - // Wait for expiry - std::this_thread::sleep_for(std::chrono::milliseconds(1100)); + // Poll until trigger expires (lifetime=1s, poll up to 3s for CI tolerance) + auto deadline = std::chrono::steady_clock::now() + std::chrono::milliseconds(3000); + while (manager_->is_active(created->id) && std::chrono::steady_clock::now() < deadline) { + std::this_thread::sleep_for(std::chrono::milliseconds(100)); + } // is_active triggers cleanup for expired triggers EXPECT_FALSE(manager_->is_active(created->id)); diff --git a/src/ros2_medkit_integration_tests/CMakeLists.txt b/src/ros2_medkit_integration_tests/CMakeLists.txt index 9887428c6..f9ea1d867 100644 --- a/src/ros2_medkit_integration_tests/CMakeLists.txt +++ b/src/ros2_medkit_integration_tests/CMakeLists.txt @@ -93,7 +93,8 @@ if(BUILD_TESTING) # Stride of 10 per test so multi-gateway tests can use get_test_port(offset). # Each test also gets a unique ROS_DOMAIN_ID to prevent DDS cross-contamination # between tests (e.g., stale DDS participants from previous tests leaking into - # subsequent test's graph discovery). + # subsequent test's graph discovery). Multi-gateway tests (e.g. peer aggregation) + # use get_test_domain_id(offset) for additional domains in the 230-232 range. set(_INTEG_PORT 9100) include(ROS2MedkitTestDomain) diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/constants.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/constants.py index 53d6a3593..aff343e7a 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/constants.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/constants.py @@ -18,6 +18,7 @@ API_BASE_PATH = '/api/v1' DEFAULT_PORT = int(os.environ.get('GATEWAY_TEST_PORT', '8080')) +DEFAULT_DOMAIN_ID = int(os.environ.get('ROS_DOMAIN_ID', '0')) DEFAULT_BASE_URL = f'http://localhost:{DEFAULT_PORT}{API_BASE_PATH}' @@ -31,6 +32,28 @@ def get_test_port(offset=0): return DEFAULT_PORT + offset +def get_test_domain_id(offset=0): + """Return a DDS domain ID for this test, optionally with an offset. + + Each integration test gets a unique ``ROS_DOMAIN_ID`` from CMake + (stride of 1, range 140-229). For offset 0, returns the assigned + domain ID directly. + + For offset > 0 (multi-gateway tests needing a second DDS domain), + returns a domain from the 230-232 range (unallocated by any package + in the ROS2MedkitTestDomain.cmake allocation table). Currently only + one integration test (peer_aggregation) uses offset=1, so domain 230 + cannot collide even when CTest runs tests in parallel. If additional + multi-domain tests are added, this scheme must be revisited. + DDS max domain ID is 232 (UDP port formula: 7400 + 250 * domain_id). + """ + if offset == 0: + return DEFAULT_DOMAIN_ID + # Use high domain IDs (230-232) for secondary domains. + # offset=1 -> 230, offset=2 -> 231, offset=3 -> 232 + return 229 + offset + + # Gateway startup GATEWAY_STARTUP_TIMEOUT = 30.0 GATEWAY_STARTUP_INTERVAL = 0.5 diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py index 27370bce8..865fec39f 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/gateway_test_case.py @@ -24,6 +24,7 @@ class TestMyFeature(GatewayTestCase): MIN_EXPECTED_APPS = 3 REQUIRED_AREAS = {'powertrain', 'chassis'} REQUIRED_APPS = {'temp_sensor', 'actuator'} + REQUIRED_FUNCTIONS = {'powertrain', 'chassis'} """ import time @@ -59,6 +60,8 @@ class GatewayTestCase(unittest.TestCase): Area IDs that must be discovered. Empty set skips area checking. REQUIRED_APPS : set of str App IDs that must be discovered. Empty set skips app checking. + REQUIRED_FUNCTIONS : set of str + Function IDs that must be discovered. Empty set skips function checking. """ @@ -69,6 +72,7 @@ class GatewayTestCase(unittest.TestCase): MIN_EXPECTED_APPS = 0 REQUIRED_AREAS: set = set() REQUIRED_APPS: set = set() + REQUIRED_FUNCTIONS: set = set() # Map of entity endpoint -> operation ID that must be discovered. # Example: {'/apps/calibration': 'calibrate'} REQUIRED_OPERATIONS: dict = {} @@ -81,7 +85,8 @@ class GatewayTestCase(unittest.TestCase): def setUpClass(cls): """Wait for gateway health, then optionally wait for discovery.""" cls._wait_for_gateway_health() - if cls.MIN_EXPECTED_APPS > 0 or cls.REQUIRED_AREAS or cls.REQUIRED_APPS: + if (cls.MIN_EXPECTED_APPS > 0 or cls.REQUIRED_AREAS + or cls.REQUIRED_APPS or cls.REQUIRED_FUNCTIONS): cls._wait_for_discovery() if cls.REQUIRED_OPERATIONS: cls._wait_for_operations() @@ -114,12 +119,13 @@ def _wait_for_gateway_health(cls): @classmethod def _wait_for_discovery(cls): - """Poll /apps and /areas until required entities are discovered. + """Poll /apps, /areas, /functions until required entities are discovered. Checks: 1. Number of apps >= ``MIN_EXPECTED_APPS`` 2. All ``REQUIRED_APPS`` IDs are present 3. All ``REQUIRED_AREAS`` IDs are present + 4. All ``REQUIRED_FUNCTIONS`` IDs are present Raises ------ @@ -132,29 +138,37 @@ def _wait_for_discovery(cls): try: apps_response = requests.get(f'{cls.BASE_URL}/apps', timeout=5) areas_response = requests.get(f'{cls.BASE_URL}/areas', timeout=5) - if apps_response.status_code == 200 and areas_response.status_code == 200: + funcs_response = requests.get(f'{cls.BASE_URL}/functions', timeout=5) + if (apps_response.status_code == 200 + and areas_response.status_code == 200 + and funcs_response.status_code == 200): apps = apps_response.json().get('items', []) areas = areas_response.json().get('items', []) + funcs = funcs_response.json().get('items', []) app_ids = {a.get('id', '') for a in apps} area_ids = {a.get('id', '') for a in areas} + func_ids = {f.get('id', '') for f in funcs} missing_areas = cls.REQUIRED_AREAS - area_ids missing_apps = cls.REQUIRED_APPS - app_ids + missing_funcs = cls.REQUIRED_FUNCTIONS - func_ids apps_ok = len(apps) >= cls.MIN_EXPECTED_APPS and not missing_apps areas_ok = not missing_areas + funcs_ok = not missing_funcs - if apps_ok and areas_ok: + if apps_ok and areas_ok and funcs_ok: print( f'Discovery complete: {len(apps)} apps, ' - f'{len(areas)} areas' + f'{len(areas)} areas, {len(funcs)} functions' ) return print( f' Waiting: {len(apps)}/{cls.MIN_EXPECTED_APPS} apps, ' - f'{len(areas)} areas. ' + f'{len(areas)} areas, {len(funcs)} functions. ' f'Missing areas: {missing_areas}, ' - f'Missing apps: {missing_apps}' + f'Missing apps: {missing_apps}, ' + f'Missing functions: {missing_funcs}' ) except requests.exceptions.RequestException: # Connection errors expected while nodes are starting; retry. @@ -163,6 +177,7 @@ def _wait_for_discovery(cls): discovered_apps = set() discovered_areas = set() + discovered_funcs = set() try: r = requests.get(f'{cls.BASE_URL}/apps', timeout=5) if r.status_code == 200: @@ -174,13 +189,19 @@ def _wait_for_discovery(cls): discovered_areas = { a.get('id', '') for a in r.json().get('items', []) } + r = requests.get(f'{cls.BASE_URL}/functions', timeout=5) + if r.status_code == 200: + discovered_funcs = { + f.get('id', '') for f in r.json().get('items', []) + } except requests.exceptions.RequestException: pass raise AssertionError( f'Discovery incomplete after {DISCOVERY_TIMEOUT}s - ' f'found {len(discovered_apps)} apps, need {cls.MIN_EXPECTED_APPS}. ' f'Missing apps: {cls.REQUIRED_APPS - discovered_apps}, ' - f'Missing areas: {cls.REQUIRED_AREAS - discovered_areas}' + f'Missing areas: {cls.REQUIRED_AREAS - discovered_areas}, ' + f'Missing functions: {cls.REQUIRED_FUNCTIONS - discovered_funcs}' ) @classmethod diff --git a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py index da9caf88e..612afd8fb 100644 --- a/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py +++ b/src/ros2_medkit_integration_tests/ros2_medkit_test_utils/launch_helpers.py @@ -181,7 +181,8 @@ def create_fault_manager_node( # Factory: demo nodes # --------------------------------------------------------------------------- -def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True): +def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True, + extra_env=None): """Create demo node launch actions. Parameters @@ -194,6 +195,10 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True): that trigger deterministic faults. If False, launch with defaults. coverage : bool If True, set GCOV_PREFIX env vars for code coverage collection. + extra_env : dict or None + Additional environment variables merged into each node's + ``additional_env``. Useful for setting ``ROS_DOMAIN_ID`` to + isolate nodes into a specific DDS domain. Returns ------- @@ -209,7 +214,9 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True): if nodes is None: nodes = ALL_DEMO_NODES - coverage_env = get_coverage_env() if coverage else {} + env = dict(get_coverage_env() if coverage else {}) + if extra_env: + env.update(extra_env) actions = [] for key in nodes: @@ -221,7 +228,7 @@ def create_demo_nodes(nodes=None, *, lidar_faulty=True, coverage=True): name=ros_name, namespace=namespace, output='screen', - additional_env=coverage_env, + additional_env=env, ) # Apply faulty parameters for lidar_sensor diff --git a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py index 1f4465c58..9a9ee33fe 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_bulk_data_api.test.py @@ -66,18 +66,22 @@ def test_bulk_data_list_categories_success(self): self.assertIn('rosbags', data['items']) def test_bulk_data_list_categories_all_entity_types(self): - """Bulk-data endpoint works for apps, components, and areas. + """Bulk-data endpoint works for apps and components. As a ros2_medkit extension, these entity types support bulk-data. - Areas provide read-only aggregated access via their child entities. - Functions also support bulk-data (tested separately with manifest). + Uses the host-derived default component (SOVD-aligned entity model). @verifies REQ_INTEROP_071 """ + # Get host component ID dynamically + comp_data = self.get_json('/components') + components = comp_data.get('items', []) + self.assertGreater(len(components), 0, 'Expected at least one component') + comp_id = components[0]['id'] + supported_endpoints = [ '/apps/lidar_sensor/bulk-data', - '/components/perception/bulk-data', - '/areas/perception/bulk-data', + f'/components/{comp_id}/bulk-data', ] for endpoint in supported_endpoints: @@ -134,14 +138,18 @@ def test_bulk_data_list_descriptors_structure(self): self.assertIn('fault_code', x_medkit) def test_bulk_data_list_descriptors_empty_result(self): - """Bulk-data returns empty array for entity without rosbags. + """Bulk-data returns empty or non-empty array for component rosbags. @verifies REQ_INTEROP_072 """ - # Use a component that likely doesn't have rosbags - # perception component bulk-data/rosbags should work + # Use the host-derived default component + comp_data = self.get_json('/components') + components = comp_data.get('items', []) + self.assertGreater(len(components), 0, 'Expected at least one component') + comp_id = components[0]['id'] + response = requests.get( - f'{self.BASE_URL}/components/perception/bulk-data/rosbags', + f'{self.BASE_URL}/components/{comp_id}/bulk-data/rosbags', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -180,16 +188,20 @@ def test_bulk_data_download_not_found(self): self.assertIn('error_code', data) def test_bulk_data_nested_entity_path(self): - """Bulk-data endpoints work for nested component entities. + """Bulk-data endpoints work for component entities. - Note: Areas do NOT support bulk-data per SOVD Table 8, so we test - with a component that has a namespace path (nested entity). + Components support bulk-data. Uses the host-derived default component. @verifies REQ_INTEROP_071 """ - # Test nested component -- components DO support bulk-data + # Use the host-derived default component + comp_data = self.get_json('/components') + components = comp_data.get('items', []) + self.assertGreater(len(components), 0, 'Expected at least one component') + comp_id = components[0]['id'] + response = requests.get( - f'{self.BASE_URL}/components/perception/bulk-data', + f'{self.BASE_URL}/components/{comp_id}/bulk-data', timeout=10 ) self.assertEqual(response.status_code, 200) diff --git a/src/ros2_medkit_integration_tests/test/features/test_data_read.test.py b/src/ros2_medkit_integration_tests/test/features/test_data_read.test.py index c743e2efb..069feb3b6 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_data_read.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_data_read.test.py @@ -16,7 +16,12 @@ """Feature tests for data read endpoints (app data, component topic data). Validates reading topic data from apps and components, data structure, -error handling, data categories/groups, and area/function data. +error handling, data categories/groups, and function data. + +With the SOVD-aligned entity model: +- Components: single host-derived default Component (aggregates all apps) +- Functions: created from namespace grouping (replace old areas) +- Areas: empty in runtime_only mode """ @@ -44,11 +49,28 @@ def generate_test_description(): class TestDataRead(GatewayTestCase): - """Data read endpoint tests for apps, components, areas, and functions.""" + """Data read endpoint tests for apps, components, and functions.""" MIN_EXPECTED_APPS = 5 REQUIRED_APPS = {'temp_sensor', 'rpm_sensor', 'pressure_sensor'} - REQUIRED_AREAS = {'powertrain', 'chassis', 'body'} + REQUIRED_FUNCTIONS = {'powertrain', 'chassis', 'body'} + + # Cache for the dynamically-discovered host component ID + _host_component_id = None + + def _get_host_component_id(self): + """Get the host component ID (cached after first lookup).""" + if TestDataRead._host_component_id is not None: + return TestDataRead._host_component_id + + data = self.get_json('/components') + components = data.get('items', []) + self.assertEqual( + len(components), 1, + f'Expected exactly 1 host component, got {len(components)}' + ) + TestDataRead._host_component_id = components[0]['id'] + return TestDataRead._host_component_id # ------------------------------------------------------------------ # App data (test_07-12) @@ -168,19 +190,20 @@ def test_app_no_topics(self): self.assertIsInstance(data['items'], list, 'Response should have items array') # ------------------------------------------------------------------ - # Component topic data (test_17-24) + # Component topic data (using host component) # ------------------------------------------------------------------ def test_component_topic_temperature(self): """GET /components/{component_id}/data/{topic_name} for temperature topic. - Uses synthetic 'powertrain' component which aggregates apps in that namespace. + Uses host-derived default component which aggregates all apps. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/powertrain/engine/temperature') response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -200,13 +223,14 @@ def test_component_topic_temperature(self): def test_component_topic_rpm(self): """GET /components/{component_id}/data/{topic_name} for RPM topic. - Uses synthetic 'powertrain' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/powertrain/engine/rpm') response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -224,13 +248,14 @@ def test_component_topic_rpm(self): def test_component_topic_pressure(self): """GET /components/{component_id}/data/{topic_name} for pressure topic. - Uses synthetic 'chassis' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/pressure') response = requests.get( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -248,13 +273,14 @@ def test_component_topic_pressure(self): def test_component_topic_data_structure(self): """GET /components/{component_id}/data/{topic_name} response structure. - Uses synthetic 'powertrain' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/powertrain/engine/temperature') response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -288,13 +314,14 @@ def test_component_nonexistent_topic_metadata_only(self): """Nonexistent topic returns 200 with metadata_only status. The gateway returns metadata about the topic even if no data is available. - Uses synthetic 'powertrain' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/some/nonexistent/topic') response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -304,7 +331,7 @@ def test_component_nonexistent_topic_metadata_only(self): self.assertIn('x-medkit', data) x_medkit = data['x-medkit'] - self.assertEqual(x_medkit['entity_id'], 'powertrain') + self.assertEqual(x_medkit['entity_id'], comp_id) self.assertEqual(x_medkit['status'], 'metadata_only') def test_component_topic_nonexistent_component_error(self): @@ -329,13 +356,14 @@ def test_component_topic_nonexistent_component_error(self): def test_component_topic_with_slashes(self): """GET with percent-encoded slashes in topic path. - Uses synthetic 'powertrain' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/powertrain/engine/temperature') response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{topic_path}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -348,10 +376,11 @@ def test_component_topic_with_slashes(self): def test_component_topic_valid_names(self): """Valid topic names work correctly. - Uses synthetic 'powertrain' component. + Uses host-derived default component. @verifies REQ_INTEROP_019 """ + comp_id = self._get_host_component_id() valid_topic_names = [ 'topic_name', 'topic_name_123', @@ -361,7 +390,7 @@ def test_component_topic_valid_names(self): for valid_topic in valid_topic_names: response = requests.get( - f'{self.BASE_URL}/components/powertrain/data/{valid_topic}', timeout=10 + f'{self.BASE_URL}/components/{comp_id}/data/{valid_topic}', timeout=10 ) self.assertEqual( response.status_code, @@ -415,108 +444,26 @@ def test_list_data_groups(self): self.assertIn('error_code', data) # ------------------------------------------------------------------ - # Area data endpoints (test_109-112) + # Function data endpoints # ------------------------------------------------------------------ - def test_list_area_data(self): - """GET /areas/{area_id}/data returns aggregated topics for area. - - Areas aggregate data from all components and apps in their hierarchy. - The powertrain area should include topics from engine sensors. - - @verifies REQ_INTEROP_018 - """ - response = requests.get( - f'{self.BASE_URL}/areas/powertrain/data', - timeout=10 - ) - self.assertEqual(response.status_code, 200) - - data = response.json() - self.assertIn('items', data) - items = data['items'] - self.assertIsInstance(items, list) - - self.assertGreater(len(items), 0, 'Area should have at least one data item') - for topic_data in items: - self.assertIn('id', topic_data) - self.assertIn('name', topic_data) - self.assertIn('x-medkit', topic_data) - x_medkit = topic_data['x-medkit'] - self.assertIn('ros2', x_medkit) - self.assertIn('direction', x_medkit['ros2']) - direction = x_medkit['ros2']['direction'] - self.assertIn(direction, ['publish', 'subscribe', 'both']) - - # Should include aggregated_from in x-medkit - self.assertIn('x-medkit', data) - self.assertIn('aggregated_from', data['x-medkit']) - self.assertIn('powertrain', data['x-medkit']['aggregated_from']) - - def test_list_area_data_nonexistent(self): - """GET /areas/{area_id}/data returns 404 for nonexistent area. - - @verifies REQ_INTEROP_018 - """ - response = requests.get( - f'{self.BASE_URL}/areas/nonexistent_area/data', - timeout=10 - ) - self.assertEqual(response.status_code, 404) - - data = response.json() - self.assertIn('error_code', data) - self.assertEqual(data['message'], 'Entity not found') - self.assertIn('parameters', data) - self.assertIn('entity_id', data['parameters']) - self.assertEqual(data['parameters'].get('entity_id'), 'nonexistent_area') + def test_list_function_data(self): + """GET /functions/{function_id}/data returns aggregated topics for function. - def test_list_area_data_root(self): - """GET /areas/root/data returns all topics system-wide. - - The root area aggregates all entities in the system. + Functions are created from namespace grouping in runtime_only mode. + The powertrain function should include topics from engine sensors. @verifies REQ_INTEROP_018 """ - response = requests.get( - f'{self.BASE_URL}/areas/root/data', - timeout=10 + data = self.poll_endpoint_until( + '/functions/powertrain/data', + condition=lambda d: d if d.get('items') else None, + timeout=15.0, ) - self.assertEqual(response.status_code, 200) - - data = response.json() self.assertIn('items', data) items = data['items'] self.assertIsInstance(items, list) - self.assertGreater(len(items), 0, 'Root area should have aggregated topics') - - def test_list_area_data_empty(self): - """GET /areas/{area_id}/data returns 200 with empty items for area with no data. - - Some areas may exist but have no direct topics - they should return 200 - with empty items, not 404. - - @verifies REQ_INTEROP_018 - """ - response = requests.get( - f'{self.BASE_URL}/areas/chassis/data', - timeout=10 - ) - self.assertEqual(response.status_code, 200) - - data = response.json() - self.assertIn('items', data) - self.assertIsInstance(data['items'], list) - self.assertIn('x-medkit', data) - self.assertIn('aggregated_from', data['x-medkit']) - self.assertIn('chassis', data['x-medkit']['aggregated_from']) - - # ------------------------------------------------------------------ - # Function data endpoints (test_113-115) - # ------------------------------------------------------------------ - - # test_list_function_data requires manifest-mode functions and is covered - # by test_23_function_data in test_scenario_discovery_manifest.test.py. + self.assertGreater(len(items), 0, 'Function should have at least one data item') def test_list_function_data_nonexistent(self): """GET /functions/{function_id}/data returns 404 for nonexistent function. diff --git a/src/ros2_medkit_integration_tests/test/features/test_data_write.test.py b/src/ros2_medkit_integration_tests/test/features/test_data_write.test.py index 7020b3ef1..688b005e9 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_data_write.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_data_write.test.py @@ -18,6 +18,8 @@ Validates publishing data to topics, validation errors for missing fields and invalid formats, nonexistent components, and invalid JSON bodies. +Uses the host-derived default component (SOVD-aligned entity model). + """ import unittest @@ -44,16 +46,34 @@ class TestDataWrite(GatewayTestCase): MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'actuator', 'controller'} + # Cache for the dynamically-discovered host component ID + _host_component_id = None + + def _get_host_component_id(self): + """Get the host component ID (cached after first lookup).""" + if TestDataWrite._host_component_id is not None: + return TestDataWrite._host_component_id + + data = self.get_json('/components') + components = data.get('items', []) + self.assertEqual( + len(components), 1, + f'Expected exactly 1 host component, got {len(components)}' + ) + TestDataWrite._host_component_id = components[0]['id'] + return TestDataWrite._host_component_id + def test_publish_brake_command(self): """PUT /components/{component_id}/data/{topic_name} publishes data. - Uses synthetic 'chassis' component. + Uses host-derived default component. @verifies REQ_INTEROP_020 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/command') response = requests.put( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', json={'type': 'std_msgs/msg/Float32', 'data': {'data': 50.0}}, timeout=10, ) @@ -75,9 +95,10 @@ def test_publish_validation_missing_type(self): @verifies REQ_INTEROP_020 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/command') response = requests.put( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', json={'data': {'data': 50.0}}, timeout=5, ) @@ -92,9 +113,10 @@ def test_publish_validation_missing_data(self): @verifies REQ_INTEROP_020 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/command') response = requests.put( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', json={'type': 'std_msgs/msg/Float32'}, timeout=5, ) @@ -119,10 +141,11 @@ def test_publish_validation_invalid_type_format(self): 'package/msg/', # Missing type (ends with /) ] + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/command') for invalid_type in invalid_types: response = requests.put( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', json={'type': invalid_type, 'data': {'data': 50.0}}, timeout=5, ) @@ -158,9 +181,10 @@ def test_publish_invalid_json_body(self): @verifies REQ_INTEROP_020 """ + comp_id = self._get_host_component_id() topic_path = self.encode_topic_path('/chassis/brakes/command') response = requests.put( - f'{self.BASE_URL}/components/chassis/data/{topic_path}', + f'{self.BASE_URL}/components/{comp_id}/data/{topic_path}', data='not valid json', headers={'Content-Type': 'application/json'}, timeout=5, diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py index efad06b8f..48070f538 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_gap_fill.test.py @@ -17,8 +17,10 @@ Integration tests for hybrid mode gap-fill configuration. Tests that gap-fill controls restrict which heuristic entities the -runtime layer can create when a manifest is present. Also tests -namespace blacklist/whitelist filtering. +runtime layer can create when a manifest is present. + +Note: Areas and Components are never created by runtime discovery. +Gap-fill only controls heuristic Apps and Functions. """ import os @@ -41,16 +43,13 @@ def generate_test_description(): pkg_share, 'config', 'examples', 'demo_nodes_manifest.yaml' ) - # Hybrid mode with restrictive gap-fill: - # - No heuristic areas (only manifest areas) - # - No heuristic components (only manifest components) - # - Apps still allowed (for linking) + # Hybrid mode with gap-fill: + # - Apps allowed (for linking) + # - Areas and Components come from manifest only (runtime never creates them) gateway_node = create_gateway_node(extra_params={ 'discovery.mode': 'hybrid', 'discovery.manifest_path': manifest_path, 'discovery.manifest_strict_validation': False, - 'discovery.merge_pipeline.gap_fill.allow_heuristic_areas': False, - 'discovery.merge_pipeline.gap_fill.allow_heuristic_components': False, 'discovery.merge_pipeline.gap_fill.allow_heuristic_apps': True, }) @@ -75,7 +74,11 @@ class TestGapFillConfig(GatewayTestCase): """Test gap-fill restrictions in hybrid mode.""" def test_only_manifest_areas_present(self): - """With allow_heuristic_areas=false, only manifest areas should exist.""" + """Areas come from manifest only - no heuristic areas from runtime. + + GET /areas only returns top-level areas; subareas are filtered + and accessible via GET /areas/{id}/subareas. + """ data = self.poll_endpoint_until( '/areas', lambda d: d if len(d.get('items', [])) >= 1 else None, @@ -83,18 +86,16 @@ def test_only_manifest_areas_present(self): ) area_ids = [a['id'] for a in data['items']] - # Manifest defines: powertrain, chassis, body, perception - # No heuristic areas from runtime namespaces should appear + # Only top-level manifest areas should appear in GET /areas + # Subareas (engine, brakes, lidar, door, lights, front-left-door) + # are filtered from the top-level listing for area_id in area_ids: self.assertIn(area_id, [ 'powertrain', 'chassis', 'body', 'perception', - # Subareas defined in manifest - 'engine', 'brakes', 'lidar', 'door', 'lights', - 'front-left-door', - ], f"Unexpected heuristic area found: {area_id}") + ], f"Unexpected area found in top-level listing: {area_id}") def test_only_manifest_components_present(self): - """With allow_heuristic_components=false, only manifest components exist.""" + """Components come from manifest only - runtime never creates components.""" data = self.poll_endpoint_until( '/components', lambda d: d if len(d.get('items', [])) >= 1 else None, @@ -136,8 +137,8 @@ def test_manifest_apps_present_and_linked(self): f"Expected manifest app {app_id} not found in apps list", ) - def test_health_shows_gap_fill_filtering(self): - """Health endpoint should show filtered_by_gap_fill count.""" + def test_health_shows_discovery_info(self): + """Health endpoint should show discovery information.""" health = self.poll_endpoint_until( '/health', lambda data: data if 'discovery' in data else None, @@ -146,8 +147,8 @@ def test_health_shows_gap_fill_filtering(self): discovery = health.get('discovery', {}) pipeline = discovery.get('pipeline', {}) - # Should have filtered some entities - self.assertIn('filtered_by_gap_fill', pipeline) + # Pipeline info should be present in hybrid mode + self.assertIn('total_entities', pipeline) @launch_testing.post_shutdown_test() diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_heuristic.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_heuristic.test.py index 591b57f45..41312d49e 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_heuristic.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_heuristic.test.py @@ -14,19 +14,19 @@ # limitations under the License. """ -Integration tests for heuristic discovery - runtime Apps and topic-only policies. +Integration tests for heuristic discovery - runtime Apps, Functions. -This test file validates the runtime discovery heuristics: +This test file validates the runtime discovery heuristics with SOVD-aligned model: - Nodes are exposed as Apps with source="heuristic" -- Synthetic components are created from namespace grouping -- TopicOnlyPolicy controls topic-based component creation -- min_topics_for_component threshold filters low-topic namespaces +- Functions are created from namespace grouping (default) +- Areas are always empty (come from manifest only) +- Components come from HostInfoProvider (single host-level component) Tests verify: - Apps have correct source field -- Components have source field (node vs topic) -- Topic-only policy IGNORE prevents component creation -- min_topics_for_component threshold works +- Functions are created from namespaces +- Areas are empty in runtime mode +- HostInfoProvider component exists """ @@ -49,15 +49,9 @@ def generate_test_description(): coverage_env = get_coverage_env() # Gateway node with runtime_only discovery mode (default) - # Uses default topic_only_policy=create_component and min_topics=1 - gateway_node = create_gateway_node( - extra_params={ - 'discovery.runtime.create_synthetic_components': True, - 'discovery.runtime.grouping_strategy': 'namespace', - 'discovery.runtime.topic_only_policy': 'create_component', - 'discovery.runtime.min_topics_for_component': 1, - }, - ) + # Functions from namespaces is enabled by default + # HostInfoProvider creates the single host-level Component + gateway_node = create_gateway_node() # Launch demo nodes to test heuristic discovery demo_nodes = [ @@ -85,9 +79,7 @@ def generate_test_description(): output='screen', additional_env=coverage_env, ), - # Node in root namespace to test duplicate component prevention - # This node publishes /root_ns_demo/temperature which could incorrectly - # create a duplicate topic-based component + # Node in root namespace launch_ros.actions.Node( package='ros2_medkit_integration_tests', executable='demo_engine_temp_sensor', @@ -118,7 +110,7 @@ class TestHeuristicAppsDiscovery(GatewayTestCase): """Integration tests for heuristic runtime discovery of Apps.""" MIN_EXPECTED_APPS = 3 - REQUIRED_AREAS = {'powertrain', 'chassis'} + REQUIRED_FUNCTIONS = {'powertrain', 'chassis'} def test_apps_have_heuristic_source(self): """Test that runtime-discovered apps have source='heuristic'.""" @@ -143,61 +135,42 @@ def test_apps_have_heuristic_source(self): f"App {app_id} has source={x_medkit['source']}, expected 'heuristic'" ) - def test_synthetic_components_created(self): - """Test that synthetic components are created by namespace grouping.""" + def test_host_component_created(self): + """Test that a single host-level Component exists from HostInfoProvider.""" data = self.get_json('/components') self.assertIn('items', data) components = data['items'] - # Should have synthetic components for powertrain, chassis namespaces - component_ids = [c.get('id') for c in components] - - # At least powertrain and chassis should exist - expected_areas = ['powertrain', 'chassis'] - for area in expected_areas: - matching = [c for c in component_ids if area in c.lower()] - self.assertTrue( - len(matching) > 0, - f"Expected component for area '{area}', found: {component_ids}" - ) + # Should have exactly one host-derived component + self.assertEqual( + len(components), 1, + f"Expected exactly 1 host component, found: {[c.get('id') for c in components]}" + ) - def test_apps_grouped_under_components(self): - """Test that apps are properly grouped under synthetic components.""" - data = self.get_json('/apps') - apps = data.get('items', []) + def test_functions_created_from_namespaces(self): + """Test that Functions are created from top-level namespaces.""" + data = self.get_json('/functions') + self.assertIn('items', data) + functions = data['items'] - for app in apps: - x_medkit = app.get('x-medkit', {}) - app_id = app.get('id') - self.assertIn('component_id', x_medkit, f'App {app_id} missing component_id') - # Component ID should not be empty for grouped apps - ros2 = x_medkit.get('ros2', {}) - if ros2.get('namespace', '').startswith('/'): - self.assertTrue( - len(x_medkit.get('component_id', '')) > 0, - f"App {app.get('id')} has empty component_id" - ) + # Should have functions for powertrain, chassis + func_ids = [f.get('id') for f in functions] + self.assertIn('powertrain', func_ids, f"Missing 'powertrain' function, found: {func_ids}") + self.assertIn('chassis', func_ids, f"Missing 'chassis' function, found: {func_ids}") - def test_areas_created_from_namespaces(self): - """Test that areas are created from top-level namespaces.""" + def test_areas_always_empty(self): + """Test that areas are always empty in runtime mode - Areas come from manifest only.""" data = self.get_json('/areas') self.assertIn('items', data) areas = data['items'] - - # Should have areas for powertrain, chassis - area_ids = [a.get('id') for a in areas] - self.assertIn('powertrain', area_ids, f"Missing 'powertrain' area, found: {area_ids}") - self.assertIn('chassis', area_ids, f"Missing 'chassis' area, found: {area_ids}") + self.assertEqual( + len(areas), 0, + f'Expected empty areas in runtime mode, ' + f'got: {[a.get("id") for a in areas]}' + ) def test_no_duplicate_component_ids(self): - """ - Test that component IDs are unique. - - Root namespace nodes publishing topics with matching prefix should - not create duplicate topic-based components. The 'root_ns_demo' node - in root namespace publishes /root_ns_demo/temperature - this should - NOT create a separate topic-based component. - """ + """Test that component IDs are unique.""" data = self.get_json('/components') self.assertIn('items', data) @@ -215,25 +188,8 @@ def test_no_duplicate_component_ids(self): f'Found duplicate component IDs: {duplicates}' ) - def test_root_namespace_node_exists_as_app_not_component(self): - """ - Test that root namespace node is an app, not a duplicate component. - - The 'root_ns_demo' node is in root namespace (/) and publishes topics - with prefix /root_ns_demo/. Without the fix, this would create both: - - A node-based app for the root area (correct) - - A topic-based component named 'root_ns_demo' (WRONG - duplicate) - - Expected: root_ns_demo exists as an app, NOT as a standalone component. - """ - # Verify root_ns_demo is NOT a standalone component - components = self.get_json('/components').get('items', []) - component_ids = [c.get('id') for c in components] - self.assertNotIn( - 'root_ns_demo', component_ids, - "'root_ns_demo' should not exist as a component - it should be an app" - ) - + def test_root_namespace_node_exists_as_app(self): + """Test that root namespace node exists as an app.""" # Verify root_ns_demo IS an app apps = self.get_json('/apps').get('items', []) app_ids = [a.get('id') for a in apps] @@ -243,49 +199,6 @@ def test_root_namespace_node_exists_as_app_not_component(self): ) -class TestTopicOnlyPolicy(GatewayTestCase): - """ - Tests for topic_only_policy configuration. - - These tests verify the three policy modes work correctly. - Note: Testing IGNORE policy requires a separate gateway instance. - """ - - def test_topic_components_have_source_field(self): - """ - Test that topic-only components (if any) have source='topic'. - - This test checks that the source field distinguishes node-based - components from topic-only components. - """ - data = self.get_json('/components') - components = data.get('items', []) - - # Check source field is present on all components - for comp in components: - # Source should be present (node, topic, synthetic, heuristic, or empty) - if 'source' in comp: - self.assertIn( - comp['source'], ['node', 'topic', 'synthetic', 'heuristic', ''], - f"Component {comp.get('id')} has unexpected source: {comp['source']}" - ) - - def test_min_topics_threshold_respected(self): - """ - Test that components with fewer topics than threshold are filtered. - - With min_topics_for_component=1 (default), all namespaces with topics - should create components. - """ - # This is a smoke test - verifying the parameter is read correctly - # Full threshold testing would require topic-only namespaces - data = self.get_json('/components') - components = data.get('items', []) - - # Should have at least the components from our demo nodes - self.assertGreaterEqual(len(components), 2) - - @launch_testing.post_shutdown_test() class TestShutdown(unittest.TestCase): """Post-shutdown tests for heuristic apps discovery tests.""" diff --git a/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py b/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py index f67cce1bd..430197554 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_discovery_legacy_mode.test.py @@ -14,10 +14,10 @@ # limitations under the License. """ -Integration tests for legacy discovery mode (create_synthetic_components: false). +Integration tests for runtime discovery without HostInfoProvider. -When synthetic components are disabled, each node becomes its own Component -in a 1:1 mapping (no namespace-based grouping). +When default_component is disabled, runtime discovery returns no components +(Components come from HostInfoProvider or manifest only). """ import unittest @@ -35,7 +35,7 @@ def generate_test_description(): gateway_node = create_gateway_node( extra_params={ - 'discovery.runtime.create_synthetic_components': False, + 'discovery.runtime.default_component.enabled': False, }, ) @@ -55,48 +55,41 @@ def generate_test_description(): # @verifies REQ_INTEROP_003 -class TestLegacyDiscoveryMode(GatewayTestCase): - """Test create_synthetic_components=false (legacy 1:1 node-to-component mode).""" - - def test_each_node_has_own_component(self): - """Each node should become its own Component (no synthetic grouping).""" - expected = ['temp_sensor', 'rpm_sensor', 'pressure_sensor'] - data = self.poll_endpoint_until( - '/components', - lambda d: d if all( - any(name in c['id'] for c in d.get('items', [])) - for name in expected - ) else None, +class TestNoHostInfoProviderMode(GatewayTestCase): + """Test runtime discovery without HostInfoProvider (no components).""" + + def test_no_components_without_host_provider(self): + """No components should exist when HostInfoProvider is disabled.""" + self.poll_endpoint_until( + '/apps', + lambda d: d if len(d.get('items', [])) >= 3 else None, timeout=60.0, ) - component_ids = [c['id'] for c in data['items']] - - # Each demo node should appear as a component - for name in expected: - self.assertTrue( - any(name in cid for cid in component_ids), - f"{name} not found in components: {component_ids}", - ) + # Now check components - should be empty + comp_data = self.get_json('/components') + components = comp_data.get('items', []) + self.assertEqual( + len(components), 0, + f"Expected no components without HostInfoProvider, " + f"got: {[c.get('id') for c in components]}", + ) - def test_no_synthetic_namespace_components(self): - """No synthetic components from namespace grouping should exist.""" + def test_apps_still_discovered(self): + """Apps should still be discovered even without HostInfoProvider.""" expected = ['temp_sensor', 'rpm_sensor', 'pressure_sensor'] data = self.poll_endpoint_until( - '/components', + '/apps', lambda d: d if all( - any(name in c['id'] for c in d.get('items', [])) + any(name in a.get('id', '') for a in d.get('items', [])) for name in expected ) else None, timeout=60.0, ) - - # With synthetic off, components should NOT have source="synthetic" - for comp in data['items']: - x_medkit = comp.get('x-medkit', {}) - source = x_medkit.get('source', '') - self.assertNotEqual( - source, 'synthetic', - f"Component {comp['id']} has source=synthetic in legacy mode", + app_ids = [a['id'] for a in data['items']] + for name in expected: + self.assertTrue( + any(name in aid for aid in app_ids), + f"{name} not found in apps: {app_ids}", ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_entity_listing.test.py b/src/ros2_medkit_integration_tests/test/features/test_entity_listing.test.py index 7d1cfdbb8..0ee1ab1e7 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_entity_listing.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_entity_listing.test.py @@ -13,9 +13,13 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Feature tests for entity listing endpoints (areas, components, apps). +"""Feature tests for entity listing endpoints (areas, components, apps, functions). -Validates discovery of entities, area components, and entity ID validation. +Validates discovery of entities with the SOVD-aligned entity model: +- Areas are empty in runtime_only mode (no synthetic areas) +- Components returns a single host-derived default Component +- Functions are created from namespace grouping +- Apps are ROS 2 nodes (unchanged) """ @@ -38,18 +42,22 @@ def generate_test_description(): class TestEntityListing(GatewayTestCase): - """Entity listing, area components, and ID validation tests.""" + """Entity listing, functions, components, and ID validation tests.""" MIN_EXPECTED_APPS = 8 - REQUIRED_AREAS = {'powertrain', 'chassis', 'body'} + REQUIRED_FUNCTIONS = {'powertrain', 'chassis', 'body'} REQUIRED_APPS = {'temp_sensor', 'long_calibration', 'lidar_sensor', 'actuator'} # ------------------------------------------------------------------ - # Area listing + # Area listing (empty in runtime_only mode) # ------------------------------------------------------------------ - def test_list_areas(self): - """GET /areas returns all discovered areas. + def test_list_areas_empty_in_runtime_mode(self): + """GET /areas returns empty items in runtime_only mode. + + With the SOVD-aligned entity model, areas are not created from + namespaces in runtime_only mode. Namespace grouping creates + Functions instead. @verifies REQ_INTEROP_003 """ @@ -57,32 +65,18 @@ def test_list_areas(self): self.assertIn('items', data) areas = data['items'] self.assertIsInstance(areas, list) - self.assertGreaterEqual(len(areas), 1) - area_ids = [area['id'] for area in areas] - self.assertIn('root', area_ids) - - def test_automotive_areas_discovery(self): - """GET /areas returns expected automotive areas (powertrain, chassis, body). - - @verifies REQ_INTEROP_003 - """ - data = self.get_json('/areas') - areas = data['items'] - area_ids = [area['id'] for area in areas] - - expected_areas = ['powertrain', 'chassis', 'body'] - for expected in expected_areas: - self.assertIn(expected, area_ids) + self.assertEqual(len(areas), 0, 'Areas should be empty in runtime_only mode') # ------------------------------------------------------------------ - # Component listing + # Component listing (single host component) # ------------------------------------------------------------------ def test_list_components(self): - """GET /components returns all discovered synthetic components. + """GET /components returns the single host-derived default Component. - With heuristic discovery (default), components are synthetic groups - created by namespace aggregation. ROS 2 nodes are exposed as Apps. + With the SOVD-aligned entity model, runtime_only mode exposes a + single Component derived from the host system info (hostname, OS, + architecture) rather than synthetic namespace-based components. @verifies REQ_INTEROP_003 """ @@ -90,65 +84,71 @@ def test_list_components(self): self.assertIn('items', data) components = data['items'] self.assertIsInstance(components, list) - # With synthetic components, we have fewer components (grouped by namespace) - # Expected: powertrain, chassis, body, perception, root (at minimum) - self.assertGreaterEqual(len(components), 4) - - # Verify response structure - all components should have required fields - for component in components: - self.assertIn('id', component) - self.assertIn('name', component) - self.assertIn('href', component) - # x-medkit contains ROS2-specific fields - self.assertIn('x-medkit', component) - x_medkit = component['x-medkit'] - # namespace may be in x-medkit.ros2.namespace - self.assertTrue( - 'ros2' in x_medkit and 'namespace' in x_medkit.get('ros2', {}), - f"Component {component['id']} should have namespace in x-medkit.ros2" - ) - - # Verify expected synthetic component IDs are present - component_ids = [comp['id'] for comp in components] - self.assertIn('powertrain', component_ids) - self.assertIn('chassis', component_ids) - self.assertIn('body', component_ids) + # Exactly one default host component + self.assertEqual( + len(components), 1, + f'Expected exactly 1 host component, got {len(components)}: ' + f'{[c.get("id") for c in components]}' + ) + + # Verify response structure + component = components[0] + self.assertIn('id', component) + self.assertIn('name', component) + self.assertIn('href', component) + # x-medkit contains source info + self.assertIn('x-medkit', component) + x_medkit = component['x-medkit'] + self.assertEqual( + x_medkit.get('source'), 'runtime', + 'Host component should have source=runtime' + ) # ------------------------------------------------------------------ - # Area components + # Function listing (namespace-derived) # ------------------------------------------------------------------ - def test_area_components_success(self): - """GET /areas/{area_id}/components returns components for valid area. + def test_list_functions(self): + """GET /functions returns namespace-derived Functions. - With synthetic components, the powertrain area contains the 'powertrain' - synthetic component which aggregates all ROS 2 nodes in that namespace. + With the SOVD-aligned entity model, namespace grouping creates + Function entities instead of Areas/Components. - @verifies REQ_INTEROP_006 + @verifies REQ_INTEROP_003 """ - # Test powertrain area - data = self.get_json('/areas/powertrain/components') + data = self.get_json('/functions') self.assertIn('items', data) - components = data['items'] - self.assertIsInstance(components, list) - self.assertGreater(len(components), 0) - - # All components should have EntityReference format with x-medkit - for component in components: - self.assertIn('id', component) - self.assertIn('name', component) - self.assertIn('href', component) - self.assertIn('x-medkit', component) - # Verify namespace is in x-medkit.ros2 - x_medkit = component['x-medkit'] - self.assertTrue( - 'ros2' in x_medkit and 'namespace' in x_medkit.get('ros2', {}), - 'Component should have namespace in x-medkit.ros2' - ) + functions = data['items'] + self.assertIsInstance(functions, list) + self.assertGreaterEqual( + len(functions), 3, + 'Expected at least 3 functions from namespace grouping' + ) + + # Verify expected function IDs from demo node namespaces + func_ids = [f['id'] for f in functions] + self.assertIn('powertrain', func_ids) + self.assertIn('chassis', func_ids) + self.assertIn('body', func_ids) + + # Verify response structure + for func in functions: + self.assertIn('id', func) + self.assertIn('name', func) + self.assertIn('href', func) + + def test_function_detail_accessible(self): + """GET /functions/{id} returns function detail for namespace-derived function. - # Verify the synthetic 'powertrain' component exists - component_ids = [comp['id'] for comp in components] - self.assertIn('powertrain', component_ids) + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/functions/powertrain') + self.assertIn('id', data) + self.assertEqual(data['id'], 'powertrain') + + # ------------------------------------------------------------------ + # Area components (404 in runtime_only mode) + # ------------------------------------------------------------------ def test_area_components_nonexistent_error(self): """GET /areas/{area_id}/components returns 404 for nonexistent area. diff --git a/src/ros2_medkit_integration_tests/test/features/test_entity_model_runtime.test.py b/src/ros2_medkit_integration_tests/test/features/test_entity_model_runtime.test.py new file mode 100644 index 000000000..2f4957b1c --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_entity_model_runtime.test.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +# 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. + +"""Integration tests for the Phase 1 SOVD entity model in runtime_only mode. + +Validates the SOVD-aligned entity model where: +- Namespaces create Function entities (not Areas) +- Areas are empty in runtime_only mode +- A single host-derived default Component is created from system info +- Apps are linked to the default Component via is-located-on + +Uses standard demo nodes that span multiple namespaces: + /powertrain/engine, /chassis/brakes, /body/door/front_left, + /body/lights, /perception/lidar +""" + +import unittest + +import launch_testing +import launch_testing.actions + +from ros2_medkit_test_utils.constants import ALLOWED_EXIT_CODES +from ros2_medkit_test_utils.gateway_test_case import GatewayTestCase +from ros2_medkit_test_utils.launch_helpers import ALL_DEMO_NODES, create_test_launch + + +def generate_test_description(): + return create_test_launch( + demo_nodes=ALL_DEMO_NODES, + fault_manager=False, + ) + + +class TestEntityModelRuntime(GatewayTestCase): + """Verify the SOVD entity model in runtime_only discovery mode.""" + + MIN_EXPECTED_APPS = 8 + REQUIRED_FUNCTIONS = {'powertrain', 'chassis', 'body', 'perception'} + REQUIRED_APPS = {'temp_sensor', 'rpm_sensor', 'actuator', 'lidar_sensor'} + + # ------------------------------------------------------------------ + # Namespaces create Functions + # ------------------------------------------------------------------ + + def test_namespaces_create_functions(self): + """Namespace grouping creates Function entities from first-level segments. + + Demo nodes in /powertrain/engine, /chassis/brakes, /body/..., /perception/lidar + should produce Functions: powertrain, chassis, body, perception. + + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/functions') + self.assertIn('items', data) + functions = data['items'] + func_ids = {f['id'] for f in functions} + + for expected_func in ('powertrain', 'chassis', 'body', 'perception'): + self.assertIn( + expected_func, func_ids, + f'Expected Function "{expected_func}" from namespace grouping. ' + f'Found: {func_ids}' + ) + + # Each Function should have proper structure + for func in functions: + self.assertIn('id', func) + self.assertIn('name', func) + self.assertIn('href', func) + + def test_function_detail_shows_namespace_source(self): + """GET /functions/{id} returns function with source=runtime. + + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/functions/powertrain') + self.assertEqual(data['id'], 'powertrain') + self.assertIn('x-medkit', data) + x_medkit = data['x-medkit'] + self.assertEqual( + x_medkit.get('source'), 'runtime', + 'Function should have source=runtime' + ) + + # ------------------------------------------------------------------ + # Areas empty in runtime_only mode + # ------------------------------------------------------------------ + + def test_areas_empty_in_runtime_mode(self): + """GET /areas returns empty items in runtime_only mode. + + With the SOVD-aligned entity model, namespaces create Functions, + not Areas. Areas require explicit manifest definition. + + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/areas') + self.assertIn('items', data) + areas = data['items'] + self.assertEqual( + len(areas), 0, + f'Areas should be empty in runtime_only mode, got: ' + f'{[a.get("id") for a in areas]}' + ) + + # ------------------------------------------------------------------ + # Default Component from host + # ------------------------------------------------------------------ + + def test_default_component_from_host(self): + """GET /components returns a single host-derived default Component. + + The default Component is created from HostInfoProvider with + hostname, OS, and architecture metadata. + + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/components') + self.assertIn('items', data) + components = data['items'] + self.assertEqual( + len(components), 1, + f'Expected exactly 1 host component in runtime_only mode, ' + f'got {len(components)}: {[c.get("id") for c in components]}' + ) + + component = components[0] + self.assertIn('id', component) + self.assertIn('name', component) + self.assertIn('href', component) + + # Verify x-medkit metadata + self.assertIn('x-medkit', component) + x_medkit = component['x-medkit'] + self.assertEqual( + x_medkit.get('source'), 'runtime', + 'Host component should have source=runtime' + ) + + def test_default_component_detail(self): + """GET /components/{id} returns component detail for the host component. + + @verifies REQ_INTEROP_003 + """ + # First get the component ID from listing + listing = self.get_json('/components') + component_id = listing['items'][0]['id'] + + detail = self.get_json(f'/components/{component_id}') + self.assertEqual(detail['id'], component_id) + self.assertIn('name', detail) + + # ------------------------------------------------------------------ + # Apps linked to default Component + # ------------------------------------------------------------------ + + def test_apps_linked_to_default_component(self): + """Apps have is-located-on link pointing to the host component. + + Every discovered App should reference the single default Component + via the SOVD is-located-on relationship. + + @verifies REQ_INTEROP_003 + """ + # Get the default component ID + comp_data = self.get_json('/components') + self.assertEqual(len(comp_data['items']), 1) + component_id = comp_data['items'][0]['id'] + + # Get app detail and check is-located-on + app_data = self.get_json('/apps/temp_sensor') + self.assertIn('_links', app_data) + links = app_data['_links'] + self.assertIn( + 'is-located-on', links, + f'App should have is-located-on link. Links: {links}' + ) + + # The link should reference the host component + expected_path = f'/api/v1/components/{component_id}' + self.assertEqual( + links['is-located-on'], expected_path, + f'is-located-on should point to host component {component_id}' + ) + + def test_app_is_located_on_endpoint(self): + """GET /apps/{id}/is-located-on returns the host component. + + The is-located-on endpoint returns a collection response with + items containing the host component. + + @verifies REQ_INTEROP_003 + """ + # Get the default component ID + comp_data = self.get_json('/components') + component_id = comp_data['items'][0]['id'] + + # Call the is-located-on endpoint (returns collection format) + data = self.get_json('/apps/temp_sensor/is-located-on') + self.assertIn('items', data) + self.assertGreaterEqual(len(data['items']), 1) + self.assertEqual( + data['items'][0]['id'], component_id, + 'is-located-on should return the host component' + ) + + def test_multiple_apps_same_component(self): + """All Apps reference the same default Component. + + In runtime_only mode, there is only one Component, so every App + must be located on it. We verify a subset of known demo apps + rather than all apps (the gateway node itself is also an app). + + @verifies REQ_INTEROP_003 + """ + comp_data = self.get_json('/components') + component_id = comp_data['items'][0]['id'] + expected_path = f'/api/v1/components/{component_id}' + + # Check a sample of demo apps from different namespaces + demo_apps = ['temp_sensor', 'actuator', 'lidar_sensor', 'controller'] + for app_id in demo_apps: + app_detail = self.get_json(f'/apps/{app_id}') + links = app_detail.get('_links', {}) + located_on = links.get('is-located-on', '') + self.assertEqual( + located_on, expected_path, + f'App {app_id} should be located on {component_id}, ' + f'but is-located-on is: {located_on}' + ) + + # ------------------------------------------------------------------ + # Cross-entity relationships + # ------------------------------------------------------------------ + + def test_function_hosts_apps(self): + """Functions group the Apps from their namespace. + + The powertrain Function (from /powertrain namespace) should host + apps like temp_sensor, rpm_sensor, calibration, long_calibration. + We verify this by checking the x-medkit.ros2.node field on app detail + which contains the FQN (e.g. /powertrain/engine/temp_sensor). + + @verifies REQ_INTEROP_003 + """ + data = self.get_json('/functions/powertrain') + self.assertEqual(data['id'], 'powertrain') + + # Check that powertrain apps are accessible via FQN + # Apps in /powertrain/engine: temp_sensor, rpm_sensor, + # calibration, long_calibration + apps_data = self.get_json('/apps') + powertrain_app_ids = set() + for app in apps_data['items']: + app_detail = self.get_json(f'/apps/{app["id"]}') + x_medkit = app_detail.get('x-medkit', {}) + ros2 = x_medkit.get('ros2', {}) + # ros2.node contains the FQN like /powertrain/engine/temp_sensor + node_fqn = ros2.get('node', '') + if node_fqn.startswith('/powertrain'): + powertrain_app_ids.add(app['id']) + + self.assertIn('temp_sensor', powertrain_app_ids) + self.assertIn('rpm_sensor', powertrain_app_ids) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_flat_entity_tree.test.py b/src/ros2_medkit_integration_tests/test/features/test_flat_entity_tree.test.py index dbdc58fec..82dbbad48 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_flat_entity_tree.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_flat_entity_tree.test.py @@ -16,12 +16,12 @@ """Integration tests for flat entity tree (no areas). Validates that the gateway works correctly in manifest_only mode with a -manifest that defines no areas. Components are top-level entities with -subcomponent hierarchy, and all entity collections return correct counts. +manifest that defines no areas. Only top-level components appear in +GET /components; subcomponents are accessible via the /subcomponents endpoint. Uses flat_robot_manifest.yaml which defines: - 0 areas - - 4 components (turtlebot3 + 3 subcomponents) + - 1 top-level component (turtlebot3) + 3 subcomponents - 4 apps (lidar-driver, turtlebot3-node, nav2-controller, robot-state-publisher) - 2 functions (autonomous-navigation, teleoperation) """ @@ -78,26 +78,24 @@ def test_areas_empty(self): self.assertEqual(len(data['items']), 0) def test_components_count(self): - """GET /components returns exactly 4 components. + """GET /components returns only top-level components. - Expected: turtlebot3 (root) + raspberry-pi, opencr-board, lds-sensor - (subcomponents). + Expected: turtlebot3 (root) only. Subcomponents (raspberry-pi, + opencr-board, lds-sensor) are filtered from the top-level listing + and accessible via GET /components/turtlebot3/subcomponents. @verifies REQ_INTEROP_003 """ data = self.poll_endpoint_until( '/components', - lambda d: d if len(d.get('items', [])) >= 4 else None, + lambda d: d if len(d.get('items', [])) >= 1 else None, timeout=30.0, ) components = data['items'] - self.assertEqual(len(components), 4) + self.assertEqual(len(components), 1) component_ids = sorted([c['id'] for c in components]) - self.assertEqual( - component_ids, - sorted(['turtlebot3', 'raspberry-pi', 'opencr-board', 'lds-sensor']), - ) + self.assertEqual(component_ids, ['turtlebot3']) def test_subcomponents_count(self): """GET /components/turtlebot3/subcomponents returns exactly 3. diff --git a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py index 414219b58..acbec83a8 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_hateoas.test.py @@ -238,10 +238,12 @@ def test_function_detail_has_capability_uris(self): def test_subareas_list_has_href(self): """GET /areas/{id}/subareas returns items with href field. + Uses 'powertrain' area which has subareas defined in the manifest. + @verifies REQ_INTEROP_004 """ response = requests.get( - f'{self.BASE_URL}/areas/root/subareas', + f'{self.BASE_URL}/areas/powertrain/subareas', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -292,10 +294,12 @@ def test_subcomponents_list_has_href(self): def test_contains_list_has_href(self): """GET /areas/{id}/contains returns items with href field. + Uses 'powertrain' area which contains components in the manifest. + @verifies REQ_INTEROP_006 """ response = requests.get( - f'{self.BASE_URL}/areas/root/contains', + f'{self.BASE_URL}/areas/powertrain/contains', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -303,8 +307,8 @@ def test_contains_list_has_href(self): data = response.json() self.assertIn('items', data) self.assertIn('_links', data) - self.assertEqual(data['_links']['self'], '/api/v1/areas/root/contains') - self.assertEqual(data['_links']['area'], '/api/v1/areas/root') + self.assertEqual(data['_links']['self'], '/api/v1/areas/powertrain/contains') + self.assertEqual(data['_links']['area'], '/api/v1/areas/powertrain') for comp in data.get('items', []): self.assertIn('id', comp, "Contained component should have 'id'") diff --git a/src/ros2_medkit_integration_tests/test/features/test_hybrid_suppression.test.py b/src/ros2_medkit_integration_tests/test/features/test_hybrid_suppression.test.py index 780819bfd..891f627b1 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_hybrid_suppression.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_hybrid_suppression.test.py @@ -22,8 +22,9 @@ no underscored duplicates of components or apps that were already linked. Manifest (demo_nodes_manifest.yaml) defines: - - 10 areas (powertrain, engine, chassis, brakes, body, door, - front-left-door, lights, perception, lidar) + - 4 top-level areas (powertrain, chassis, body, perception) + - 6 subareas (engine, brakes, door, front-left-door, lights, lidar) + (only accessible via /areas/{id}/subareas, not in GET /areas) - 9 components (engine-ecu, temp-sensor-hw, rpm-sensor-hw, brake-ecu, brake-pressure-sensor-hw, brake-actuator-hw, door-sensor-hw, light-module, lidar-unit) @@ -53,10 +54,16 @@ ) # Expected manifest entity counts and IDs -MANIFEST_AREAS = { +# All areas defined in the manifest (top-level + subareas) +MANIFEST_ALL_AREAS = { 'powertrain', 'engine', 'chassis', 'brakes', 'body', 'door', 'front-left-door', 'lights', 'perception', 'lidar', } +# Only top-level areas appear in GET /areas; subareas are filtered +MANIFEST_TOP_LEVEL_AREAS = { + 'powertrain', 'chassis', 'body', 'perception', +} +MANIFEST_SUBAREAS = MANIFEST_ALL_AREAS - MANIFEST_TOP_LEVEL_AREAS MANIFEST_COMPONENTS = { 'engine-ecu', 'temp-sensor-hw', 'rpm-sensor-hw', 'brake-ecu', 'brake-pressure-sensor-hw', 'brake-actuator-hw', 'door-sensor-hw', @@ -82,14 +89,14 @@ def generate_test_description(): # Hybrid mode with gap-fill restrictions: only manifest entities allowed. # All demo nodes match manifest ros_bindings, so the runtime layer # should link them to manifest apps (not create duplicates). - # Gap-fill blocks heuristic entities for non-manifest nodes (e.g. the + # Gap-fill blocks heuristic apps for non-manifest nodes (e.g. the # gateway node itself, param client) so we can assert exact counts. + # Note: Areas and Components are never created by runtime discovery, + # so no gap-fill control is needed for them. gateway_node = create_gateway_node(extra_params={ 'discovery.mode': 'hybrid', 'discovery.manifest_path': manifest_path, 'discovery.manifest_strict_validation': False, - 'discovery.merge_pipeline.gap_fill.allow_heuristic_areas': False, - 'discovery.merge_pipeline.gap_fill.allow_heuristic_components': False, 'discovery.merge_pipeline.gap_fill.allow_heuristic_apps': False, }) @@ -112,23 +119,29 @@ class TestHybridSuppression(GatewayTestCase): """Verify hybrid mode suppresses duplicate entities after linking.""" # Wait for all manifest apps to be discovered before running tests. + # REQUIRED_AREAS only checks top-level areas (subareas are filtered). MIN_EXPECTED_APPS = len(MANIFEST_APPS) - REQUIRED_AREAS = MANIFEST_AREAS + REQUIRED_AREAS = MANIFEST_TOP_LEVEL_AREAS REQUIRED_APPS = MANIFEST_APPS def test_exact_area_count(self): - """Area count must match manifest exactly - no synthetic extras.""" + """Top-level area count must match manifest - no synthetic extras. + + Subareas are filtered from GET /areas and only accessible via + GET /areas/{id}/subareas. + """ # @verifies REQ_INTEROP_003 data = self.get_json('/areas') area_ids = {a['id'] for a in data['items']} self.assertEqual( - area_ids, MANIFEST_AREAS, - f'Area mismatch. Extra: {area_ids - MANIFEST_AREAS}, ' - f'Missing: {MANIFEST_AREAS - area_ids}', + area_ids, MANIFEST_TOP_LEVEL_AREAS, + f'Area mismatch. Extra: {area_ids - MANIFEST_TOP_LEVEL_AREAS}, ' + f'Missing: {MANIFEST_TOP_LEVEL_AREAS - area_ids}', ) self.assertEqual( - len(data['items']), len(MANIFEST_AREAS), - f'Expected {len(MANIFEST_AREAS)} areas, got {len(data["items"])}: ' + len(data['items']), len(MANIFEST_TOP_LEVEL_AREAS), + f'Expected {len(MANIFEST_TOP_LEVEL_AREAS)} top-level areas, ' + f'got {len(data["items"])}: ' f'{[a["id"] for a in data["items"]]}', ) @@ -228,13 +241,13 @@ def test_no_underscored_component_duplicates(self): ) def test_no_root_or_synthetic_areas(self): - """No 'root' or underscored synthetic areas should exist.""" + """No 'root' or underscored synthetic areas should exist in top-level.""" # @verifies REQ_INTEROP_003 data = self.get_json('/areas') area_ids = [a['id'] for a in data['items']] synthetic = [ aid for aid in area_ids - if aid == 'root' or (aid not in MANIFEST_AREAS) + if aid == 'root' or (aid not in MANIFEST_TOP_LEVEL_AREAS) ] self.assertEqual( synthetic, [], diff --git a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py index b9908372c..df537f13e 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_logging_api.test.py @@ -46,7 +46,7 @@ class TestLoggingApi(GatewayTestCase): MIN_EXPECTED_APPS = 1 REQUIRED_APPS = {'temp_sensor'} - REQUIRED_AREAS = {'powertrain'} + REQUIRED_FUNCTIONS = {'powertrain'} # ------------------------------------------------------------------ # GET /apps/{id}/logs @@ -292,36 +292,36 @@ def test_component_put_logs_configuration_returns_204(self): self.assertEqual(data['severity_filter'], 'info') # ------------------------------------------------------------------ - # GET /areas/{id}/logs (prefix match on area namespace) + # GET /functions/{id}/logs (prefix match on function namespace) # ------------------------------------------------------------------ - def test_area_get_logs_returns_200(self): - """GET /areas/{id}/logs returns 200 with items array. + def test_function_get_logs_returns_200(self): + """GET /functions/{id}/logs returns 200 with items array. - Areas use namespace prefix matching - all nodes under the area - namespace are included. This is a ros2_medkit extension (SOVD - defines resource collections only for apps/components). + Functions use namespace prefix matching in runtime_only mode. + This is a ros2_medkit extension (SOVD defines resource + collections only for apps/components). # @verifies REQ_INTEROP_061 """ - areas = self.get_json('/areas')['items'] - self.assertGreater(len(areas), 0, 'Expected at least one area') - area_id = areas[0]['id'] + functions = self.get_json('/functions')['items'] + self.assertGreater(len(functions), 0, 'Expected at least one function') + func_id = functions[0]['id'] - data = self.get_json(f'/areas/{area_id}/logs') + data = self.get_json(f'/functions/{func_id}/logs') self.assertIn('items', data) self.assertIsInstance(data['items'], list) - def test_area_get_logs_configuration_returns_200(self): - """GET /areas/{id}/logs/configuration returns default config. + def test_function_get_logs_configuration_returns_200(self): + """GET /functions/{id}/logs/configuration returns default config. # @verifies REQ_INTEROP_063 """ - areas = self.get_json('/areas')['items'] - self.assertGreater(len(areas), 0, 'Expected at least one area') - area_id = areas[0]['id'] + functions = self.get_json('/functions')['items'] + self.assertGreater(len(functions), 0, 'Expected at least one function') + func_id = functions[0]['id'] - data = self.get_json(f'/areas/{area_id}/logs/configuration') + data = self.get_json(f'/functions/{func_id}/logs/configuration') self.assertIn('severity_filter', data) self.assertIn('max_entries', data) diff --git a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py index c27596463..a64627e0c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_operations_api.test.py @@ -18,6 +18,8 @@ Validates operation listing, service calls, action details, operation schema, error handling, and execution listing. +Uses host-derived default component (SOVD-aligned entity model). + """ import unittest @@ -45,6 +47,23 @@ class TestOperationsApi(GatewayTestCase): REQUIRED_APPS = {'calibration', 'long_calibration'} REQUIRED_OPERATIONS = {'/apps/calibration': 'calibrate'} + # Cache for the dynamically-discovered host component ID + _host_component_id = None + + def _get_host_component_id(self): + """Get the host component ID (cached after first lookup).""" + if TestOperationsApi._host_component_id is not None: + return TestOperationsApi._host_component_id + + data = self.get_json('/components') + components = data.get('items', []) + self.assertEqual( + len(components), 1, + f'Expected exactly 1 host component, got {len(components)}' + ) + TestOperationsApi._host_component_id = components[0]['id'] + return TestOperationsApi._host_component_id + # ------------------------------------------------------------------ # Service calls (test_31-36) # ------------------------------------------------------------------ @@ -52,7 +71,7 @@ class TestOperationsApi(GatewayTestCase): def test_operation_call_calibrate_service(self): """POST /apps/{app_id}/operations/{op}/executions calls a service. - Operations are exposed on Apps (ROS 2 nodes), not synthetic Components. + Operations are exposed on Apps (ROS 2 nodes), not Components. @verifies REQ_INTEROP_035 """ @@ -279,9 +298,12 @@ def test_service_operation_has_type_info_schema(self): def test_get_operation_details_for_service(self): """GET /{entity}/operations/{op-id} returns operation details for service. + Uses host-derived default component which aggregates all apps' operations. + @verifies REQ_INTEROP_034 """ - data = self.get_json('/components/powertrain/operations') + comp_id = self._get_host_component_id() + data = self.get_json(f'/components/{comp_id}/operations') self.assertIn('items', data) operations = data['items'] self.assertGreater(len(operations), 0, 'Component should have operations') @@ -301,7 +323,7 @@ def test_get_operation_details_for_service(self): # Get the operation details response = requests.get( - f'{self.BASE_URL}/components/powertrain/operations/{operation_id}', + f'{self.BASE_URL}/components/{comp_id}/operations/{operation_id}', timeout=10 ) self.assertEqual(response.status_code, 200) @@ -369,8 +391,9 @@ def test_get_operation_not_found(self): @verifies REQ_INTEROP_034 """ + comp_id = self._get_host_component_id() response = requests.get( - f'{self.BASE_URL}/components/powertrain/operations/nonexistent_op', + f'{self.BASE_URL}/components/{comp_id}/operations/nonexistent_op', timeout=10 ) self.assertEqual(response.status_code, 404) @@ -428,7 +451,8 @@ def test_create_execution_for_service(self): @verifies REQ_INTEROP_035 """ - data = self.get_json('/components/powertrain/operations') + comp_id = self._get_host_component_id() + data = self.get_json(f'/components/{comp_id}/operations') operations = data['items'] service_op = None @@ -445,7 +469,7 @@ def test_create_execution_for_service(self): # Call the service response = requests.post( - f'{self.BASE_URL}/components/powertrain/operations/{operation_id}/executions', + f'{self.BASE_URL}/components/{comp_id}/operations/{operation_id}/executions', json={'parameters': {}}, timeout=30 ) @@ -464,7 +488,8 @@ def test_cancel_nonexistent_execution(self): @verifies REQ_INTEROP_039 """ - url = (f'{self.BASE_URL}/components/powertrain/operations/' + comp_id = self._get_host_component_id() + url = (f'{self.BASE_URL}/components/{comp_id}/operations/' 'nonexistent_op/executions/fake-exec-id') response = requests.delete(url, timeout=10) self.assertEqual(response.status_code, 404) diff --git a/src/ros2_medkit_integration_tests/test/features/test_peer_aggregation.test.py b/src/ros2_medkit_integration_tests/test/features/test_peer_aggregation.test.py new file mode 100644 index 000000000..2ff6fde8f --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_peer_aggregation.test.py @@ -0,0 +1,371 @@ +#!/usr/bin/env python3 +# 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. + +"""Integration tests for peer aggregation with two gateway instances. + +Launches TWO gateway instances on different ports AND in separate DDS domains: +- Primary gateway (port offset 0, domain offset 0): aggregation enabled, + static peer pointing to the secondary gateway. Manages powertrain demo + nodes (temp_sensor, rpm_sensor). +- Peer gateway (port offset 1, domain offset 1): standard gateway, no + aggregation. Manages chassis demo nodes (pressure_sensor, actuator). + +DDS domain isolation ensures the primary cannot discover peer nodes via DDS +graph introspection - it can only learn about them through HTTP aggregation. +This validates that the aggregation layer is doing the actual work. + +Tests verify: +- DDS isolation: peer gateway does NOT see primary's nodes +- Merged entity list: primary /apps includes apps from both gateways +- Request forwarding: GET /apps/{remote_app}/data is forwarded to peer +- Health shows peers: GET /health includes peer status +""" + +import time +import unittest + +from launch import LaunchDescription +from launch.actions import SetEnvironmentVariable, TimerAction +import launch_ros.actions +import launch_testing +import launch_testing.actions +import requests + +from ros2_medkit_test_utils.constants import ( + ALLOWED_EXIT_CODES, + API_BASE_PATH, + DISCOVERY_TIMEOUT, + GATEWAY_STARTUP_INTERVAL, + GATEWAY_STARTUP_TIMEOUT, + get_test_domain_id, + get_test_port, +) +from ros2_medkit_test_utils.launch_helpers import ( + create_demo_nodes, + create_gateway_node, +) + + +# Port assignments: primary at base port, peer at base + 1 +PRIMARY_PORT = get_test_port(0) +PEER_PORT = get_test_port(1) +PRIMARY_URL = f'http://localhost:{PRIMARY_PORT}{API_BASE_PATH}' +PEER_URL = f'http://localhost:{PEER_PORT}{API_BASE_PATH}' + +# DDS domain isolation: each gateway and its demo nodes run in a separate DDS +# domain so that the peer gateway cannot discover the primary's nodes via DDS. +# The primary can only learn about peer entities through aggregation (HTTP). +PRIMARY_DOMAIN_ID = get_test_domain_id(0) +PEER_DOMAIN_ID = get_test_domain_id(1) + +# Demo nodes split between gateways: +# Primary manages powertrain nodes +PRIMARY_NODES = ['temp_sensor', 'rpm_sensor'] +# Peer manages chassis nodes +PEER_NODES = ['pressure_sensor', 'actuator'] + + +def generate_test_description(): + peer_domain_env = {'ROS_DOMAIN_ID': str(PEER_DOMAIN_ID)} + + # Primary gateway: aggregation enabled with static peer, in PRIMARY_DOMAIN_ID + primary_gateway = create_gateway_node( + port=PRIMARY_PORT, + extra_params={ + 'aggregation.enabled': True, + 'aggregation.timeout_ms': 5000, + 'aggregation.announce': False, + 'aggregation.discover': False, + 'aggregation.peer_urls': [f'http://localhost:{PEER_PORT}'], + 'aggregation.peer_names': ['peer_gateway'], + }, + ) + + # Peer gateway: standard configuration, no aggregation, in PEER_DOMAIN_ID + peer_gateway = launch_ros.actions.Node( + package='ros2_medkit_gateway', + executable='gateway_node', + name='peer_gateway_node', + output='screen', + parameters=[{ + 'refresh_interval_ms': 1000, + 'server.port': PEER_PORT, + }], + additional_env=peer_domain_env, + ) + + # Demo nodes: each set runs in its gateway's DDS domain. + # Primary nodes inherit ROS_DOMAIN_ID from SetEnvironmentVariable below. + # Peer nodes get an explicit override via extra_env. + primary_demo_nodes = create_demo_nodes(PRIMARY_NODES, lidar_faulty=False) + peer_demo_nodes = create_demo_nodes( + PEER_NODES, lidar_faulty=False, extra_env=peer_domain_env, + ) + + delayed = TimerAction( + period=2.0, + actions=primary_demo_nodes + peer_demo_nodes, + ) + + # SetEnvironmentVariable sets the default ROS_DOMAIN_ID for the launch + # context (primary domain). The peer nodes override it via additional_env. + launch_description = LaunchDescription([ + SetEnvironmentVariable('ROS_DOMAIN_ID', str(PRIMARY_DOMAIN_ID)), + primary_gateway, + peer_gateway, + delayed, + launch_testing.actions.ReadyToTest(), + ]) + + return ( + launch_description, + {'gateway_node': primary_gateway, 'peer_gateway': peer_gateway}, + ) + + +class TestPeerAggregation(unittest.TestCase): + """Verify peer aggregation between two gateway instances.""" + + @classmethod + def setUpClass(cls): + """Wait for both gateways to be healthy and discover their nodes.""" + cls._wait_for_health(PRIMARY_URL, 'Primary') + cls._wait_for_health(PEER_URL, 'Peer') + + # Wait for peer gateway to discover its nodes + cls._wait_for_apps(PEER_URL, {'pressure_sensor', 'actuator'}, 'Peer') + + # Wait for primary gateway to discover its own nodes AND merge peer's + # This requires multiple refresh cycles for aggregation to kick in + cls._wait_for_apps( + PRIMARY_URL, + {'temp_sensor', 'rpm_sensor', 'pressure_sensor', 'actuator'}, + 'Primary (merged)', + ) + + @classmethod + def _wait_for_health(cls, base_url, label): + """Poll /health until a gateway responds with 200.""" + deadline = time.monotonic() + GATEWAY_STARTUP_TIMEOUT + while time.monotonic() < deadline: + try: + response = requests.get(f'{base_url}/health', timeout=2) + if response.status_code == 200: + print(f'{label} gateway is healthy') + return + except requests.exceptions.RequestException: + pass + time.sleep(GATEWAY_STARTUP_INTERVAL) + raise AssertionError( + f'{label} gateway not responding after {GATEWAY_STARTUP_TIMEOUT}s' + ) + + @classmethod + def _wait_for_apps(cls, base_url, required_apps, label): + """Poll /apps until all required app IDs are present.""" + deadline = time.monotonic() + DISCOVERY_TIMEOUT + while time.monotonic() < deadline: + try: + response = requests.get(f'{base_url}/apps', timeout=5) + if response.status_code == 200: + items = response.json().get('items', []) + found_ids = {a.get('id', '') for a in items} + missing = required_apps - found_ids + if not missing: + print( + f'{label}: all apps discovered ({len(found_ids)} total)' + ) + return + print( + f' {label}: waiting for {missing} ' + f'(have {found_ids})' + ) + except requests.exceptions.RequestException: + pass + time.sleep(1.0) + raise AssertionError( + f'{label}: apps not discovered after {DISCOVERY_TIMEOUT}s. ' + f'Missing: {required_apps}' + ) + + # ------------------------------------------------------------------ + # Merged entity list + # ------------------------------------------------------------------ + + def test_primary_apps_include_peer_apps(self): + """Primary gateway's /apps includes apps from both gateways. + + The primary discovers temp_sensor and rpm_sensor locally, and + merges pressure_sensor and actuator from the peer gateway. + """ + response = requests.get(f'{PRIMARY_URL}/apps', timeout=10) + self.assertEqual(response.status_code, 200) + items = response.json().get('items', []) + app_ids = {a['id'] for a in items} + + # Local apps + self.assertIn('temp_sensor', app_ids) + self.assertIn('rpm_sensor', app_ids) + # Peer apps (merged via aggregation) + self.assertIn('pressure_sensor', app_ids) + self.assertIn('actuator', app_ids) + + def test_peer_no_aggregation_no_peers(self): + """Peer gateway does not aggregate - it has no peer status. + + The peer gateway runs in its own DDS domain and has aggregation + disabled, so it should NOT have 'peers' in its health response + or see the primary's nodes. + """ + response = requests.get(f'{PEER_URL}/health', timeout=10) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertNotIn( + 'peers', data, + 'Peer gateway should not report peers (aggregation disabled)' + ) + + # Peer's own apps should still be discovered + apps_response = requests.get(f'{PEER_URL}/apps', timeout=10) + self.assertEqual(apps_response.status_code, 200) + items = apps_response.json().get('items', []) + app_ids = {a['id'] for a in items} + self.assertIn('pressure_sensor', app_ids) + self.assertIn('actuator', app_ids) + + def test_peer_does_not_see_primary_nodes_via_dds(self): + """Peer gateway must NOT see primary's nodes (DDS domain isolation). + + Because the two gateways run in separate DDS domains, the peer + gateway should only see its own chassis nodes (pressure_sensor, + actuator) and NOT the primary's powertrain nodes (temp_sensor, + rpm_sensor). This proves the DDS isolation is working. + """ + response = requests.get(f'{PEER_URL}/apps', timeout=10) + self.assertEqual(response.status_code, 200) + items = response.json().get('items', []) + app_ids = {a['id'] for a in items} + + # Peer must NOT see primary's nodes + self.assertNotIn( + 'temp_sensor', app_ids, + 'Peer gateway should not discover primary nodes via DDS' + ) + self.assertNotIn( + 'rpm_sensor', app_ids, + 'Peer gateway should not discover primary nodes via DDS' + ) + + def test_primary_functions_include_peer_functions(self): + """Primary gateway merges Functions from both gateways. + + Primary has powertrain (from /powertrain namespace), peer has + chassis (from /chassis namespace). Primary should see both. + """ + response = requests.get(f'{PRIMARY_URL}/functions', timeout=10) + self.assertEqual(response.status_code, 200) + items = response.json().get('items', []) + func_ids = {f['id'] for f in items} + + self.assertIn('powertrain', func_ids) + self.assertIn('chassis', func_ids) + + # ------------------------------------------------------------------ + # Request forwarding + # ------------------------------------------------------------------ + + def test_forward_data_request_to_peer(self): + """GET /apps/{remote_app}/data on primary is forwarded to peer. + + pressure_sensor lives on the peer gateway. When the primary receives + a data request for it, it should forward the request to the peer + and return the peer's response. + """ + response = requests.get( + f'{PRIMARY_URL}/apps/pressure_sensor/data', timeout=10 + ) + self.assertEqual( + response.status_code, 200, + f'Expected 200 for forwarded data request, got ' + f'{response.status_code}: {response.text}' + ) + data = response.json() + self.assertIn('items', data) + + def test_forward_app_detail_to_peer(self): + """GET /apps/{remote_app} on primary returns peer app detail. + + The primary should transparently proxy the request and return + the app detail from the peer gateway. + """ + response = requests.get( + f'{PRIMARY_URL}/apps/actuator', timeout=10 + ) + self.assertEqual( + response.status_code, 200, + f'Expected 200 for forwarded app detail, got ' + f'{response.status_code}: {response.text}' + ) + data = response.json() + self.assertEqual(data['id'], 'actuator') + + # ------------------------------------------------------------------ + # Health shows peers + # ------------------------------------------------------------------ + + def test_health_includes_peer_status(self): + """GET /health on primary includes peer status information. + + When aggregation is enabled, the health endpoint should include + a 'peers' array showing connected peer gateways and their status. + """ + response = requests.get(f'{PRIMARY_URL}/health', timeout=10) + self.assertEqual(response.status_code, 200) + data = response.json() + + self.assertIn( + 'peers', data, + 'Health response should include peers when aggregation is enabled' + ) + peers = data['peers'] + self.assertIsInstance(peers, list) + self.assertGreaterEqual(len(peers), 1) + + # Find our peer gateway + peer_entry = None + for peer in peers: + if peer.get('name') == 'peer_gateway': + peer_entry = peer + break + self.assertIsNotNone( + peer_entry, + f'Expected peer_gateway in peers list: {peers}' + ) + assert peer_entry is not None # type narrowing for Pyright + self.assertEqual(peer_entry['status'], 'online') + self.assertIn(str(PEER_PORT), peer_entry['url']) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + + def test_exit_codes(self, proc_info): + """Check all processes exited cleanly (SIGTERM allowed).""" + for info in proc_info: + self.assertIn( + info.returncode, ALLOWED_EXIT_CODES, + f'{info.process_name} exited with code {info.returncode}' + ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_triggers_faults.test.py b/src/ros2_medkit_integration_tests/test/features/test_triggers_faults.test.py index 00d0c3b37..223c617cd 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_triggers_faults.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_triggers_faults.test.py @@ -239,17 +239,10 @@ def test_11_sse_fault_event_has_correct_headers(self): def test_20_fault_trigger_on_component(self): """Fault triggers work on component entities.""" - # Find the perception component (lidar's synthetic component) + # Find the host-derived default component (all apps belong to it) r = requests.get(f'{self.BASE_URL}/components', timeout=5) components = r.json().get('items', []) - comp_id = None - for comp in components: - cid = comp.get('id', '') - if 'perception' in cid or 'lidar' in cid: - comp_id = cid - break - if not comp_id and components: - comp_id = components[0]['id'] + comp_id = components[0]['id'] if components else None if not comp_id: self.fail('No components discovered') diff --git a/src/ros2_medkit_integration_tests/test/features/test_triggers_hierarchy.test.py b/src/ros2_medkit_integration_tests/test/features/test_triggers_hierarchy.test.py index 47cf8e9c6..33055612c 100644 --- a/src/ros2_medkit_integration_tests/test/features/test_triggers_hierarchy.test.py +++ b/src/ros2_medkit_integration_tests/test/features/test_triggers_hierarchy.test.py @@ -91,7 +91,9 @@ class TestTriggersHierarchy(GatewayTestCase): # Manifest entities need time to be loaded and for apps to go online MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'engine-temp-sensor', 'engine-rpm-sensor'} - REQUIRED_AREAS = {'powertrain', 'engine'} + # Only top-level areas appear in GET /areas; 'engine' is a subarea + # of 'powertrain' and is filtered from the top-level listing. + REQUIRED_AREAS = {'powertrain'} def _delete_trigger(self, entity_type, entity_id, trigger_id): """Delete a trigger, ignoring errors for cleanup.""" diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_action_lifecycle.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_action_lifecycle.test.py index 1a8b72b08..3fefa6971 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_action_lifecycle.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_action_lifecycle.test.py @@ -147,8 +147,13 @@ def test_03_service_execution_returns_immediately(self): @verifies REQ_INTEROP_035 """ - # Find a service operation on the calibration app or powertrain component - data = self.get_json('/components/powertrain/operations') + # Find a service operation on the host-derived default component + comp_data = self.get_json('/components') + components = comp_data.get('items', []) + self.assertGreater(len(components), 0, 'Expected at least one component') + comp_id = components[0]['id'] + + data = self.get_json(f'/components/{comp_id}/operations') operations = data['items'] service_op = None @@ -158,13 +163,13 @@ def test_03_service_execution_returns_immediately(self): break if service_op is None: - self.fail('No service operations found on powertrain component') + self.fail('No service operations found on component') operation_id = service_op['id'] # Call the service response = requests.post( - f'{self.BASE_URL}/components/powertrain/operations/' + f'{self.BASE_URL}/components/{comp_id}/operations/' f'{operation_id}/executions', json={'parameters': {}}, timeout=30, diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_hybrid.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_hybrid.test.py index b9b040d09..7cde50b92 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_hybrid.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_hybrid.test.py @@ -99,7 +99,9 @@ class TestScenarioDiscoveryHybrid(GatewayTestCase): # Hybrid mode needs all demo nodes linked before tests run MIN_EXPECTED_APPS = 5 - REQUIRED_AREAS = {'powertrain', 'chassis', 'body', 'perception', 'engine'} + # Only top-level areas appear in GET /areas; subareas like 'engine' + # are filtered from the top-level listing and accessed via /subareas. + REQUIRED_AREAS = {'powertrain', 'chassis', 'body', 'perception'} REQUIRED_APPS = { 'engine-temp-sensor', 'engine-rpm-sensor', 'brake-pressure-sensor', 'lidar-sensor', @@ -110,13 +112,16 @@ class TestScenarioDiscoveryHybrid(GatewayTestCase): # ========================================================================= def test_01_areas_from_manifest(self): - """Areas are loaded from manifest in hybrid mode. + """Top-level areas are loaded from manifest in hybrid mode. + + Subareas (e.g. 'engine') are filtered from the top-level listing + and accessible via GET /areas/{id}/subareas. @verifies REQ_INTEROP_003 """ self.assert_entity_list_contains( 'areas', - {'powertrain', 'chassis', 'body', 'perception', 'engine'}, + {'powertrain', 'chassis', 'body', 'perception'}, ) def test_02_area_with_description(self): diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py index 87b43c510..7aea73fea 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_discovery_manifest.test.py @@ -107,7 +107,10 @@ class TestScenarioDiscoveryManifest(GatewayTestCase): # ========================================================================= def test_01_list_areas(self): - """GET /areas returns all manifest-defined areas including subareas. + """GET /areas returns only top-level areas (subareas are filtered out). + + Sub-entities (areas with parent_area_id) are only accessible via + GET /areas/{id}/subareas, not in the top-level listing. @verifies REQ_INTEROP_003 """ @@ -121,9 +124,12 @@ def test_01_list_areas(self): for area_id in ['powertrain', 'chassis', 'body', 'perception']: self.assertIn(area_id, area_ids, f'Missing top-level area: {area_id}') - # Subareas + # Subareas must NOT appear in top-level listing for area_id in ['engine', 'brakes', 'lidar']: - self.assertIn(area_id, area_ids, f'Missing subarea: {area_id}') + self.assertNotIn( + area_id, area_ids, + f'Subarea {area_id} should not appear in top-level /areas listing' + ) def test_02_get_area_details(self): """GET /areas/{id} returns area with capabilities and links. diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_fault_lifecycle.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_fault_lifecycle.test.py index 2bc41fc10..b48dd07ed 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_fault_lifecycle.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_fault_lifecycle.test.py @@ -55,7 +55,7 @@ class TestScenarioFaultLifecycle(GatewayTestCase): MIN_EXPECTED_APPS = 1 REQUIRED_APPS = {'lidar_sensor'} - REQUIRED_AREAS = {'perception'} + REQUIRED_FUNCTIONS = {'perception'} LIDAR_ENDPOINT = '/apps/lidar_sensor' FAULT_CODE = 'LIDAR_RANGE_INVALID' @@ -90,8 +90,10 @@ def test_02_delete_all_faults_for_component(self): @verifies REQ_INTEROP_014 """ - # The lidar_sensor is under /perception namespace - component_id = 'perception' + # Use the host-derived default component (all apps belong to it) + components = self.get_json('/components')['items'] + self.assertGreater(len(components), 0, 'Expected at least one component') + component_id = components[0]['id'] # Clear all faults (should succeed even if empty) response = self.delete_request( diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_hierarchy_diagnostics.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_hierarchy_diagnostics.test.py index f87970465..aad1ff0af 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_hierarchy_diagnostics.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_hierarchy_diagnostics.test.py @@ -101,7 +101,9 @@ class TestScenarioHierarchyDiagnostics(GatewayTestCase): MIN_EXPECTED_APPS = 2 REQUIRED_APPS = {'engine-temp-sensor', 'engine-rpm-sensor'} - REQUIRED_AREAS = {'powertrain', 'engine'} + # Only top-level areas appear in GET /areas; 'engine' is a subarea + # of 'powertrain' and is filtered from the top-level listing. + REQUIRED_AREAS = {'powertrain'} app_topic_id = None app_resource_uri = None diff --git a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_thermal_protection.test.py b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_thermal_protection.test.py index 495695a27..b84d35e8c 100644 --- a/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_thermal_protection.test.py +++ b/src/ros2_medkit_integration_tests/test/scenarios/test_scenario_thermal_protection.test.py @@ -208,23 +208,16 @@ def test_03_create_component_fault_trigger(self): """Create trigger 3: OnChange on component faults collection. Catches any fault reported on the component level during thermal - events. Uses the synthetic component that groups the temp_sensor. + events. Uses the host-derived default component that hosts all apps. """ - # Find the component that hosts temp_sensor + # Find the component that hosts temp_sensor (host-derived default) r = requests.get(f'{self.BASE_URL}/components', timeout=5) components = r.json().get('items', []) self.assertTrue( len(components) > 0, 'No components discovered' ) - comp_id = None - for comp in components: - cid = comp.get('id', '') - if 'powertrain' in cid or 'engine' in cid: - comp_id = cid - break - if not comp_id: - comp_id = components[0]['id'] + comp_id = components[0]['id'] resource = f'/api/v1/components/{comp_id}/faults' body = {