diff --git a/.devcontainer/Dockerfile b/.devcontainer/Dockerfile index 8e0b92202..72696adfe 100644 --- a/.devcontainer/Dockerfile +++ b/.devcontainer/Dockerfile @@ -39,6 +39,7 @@ RUN DEBIAN_FRONTEND=noninteractive apt-get update && apt-get install -y \ graphviz \ plantuml \ lcov \ + libsystemd-dev \ && locale-gen en_US.UTF-8 \ && rm -rf /var/lib/apt/lists/* diff --git a/docs/api/rest.rst b/docs/api/rest.rst index 851037e43..50d5a33cd 100644 --- a/docs/api/rest.rst +++ b/docs/api/rest.rst @@ -1290,6 +1290,164 @@ JWT-based authentication with Role-Based Access Control (RBAC). {"token": "dGhpcyBpcyBhIHJlZnJlc2g..."} +Vendor Extension Endpoints (Plugins) +------------------------------------- + +Plugin-registered endpoints use the ``x-medkit-`` prefix following the SOVD vendor extension +mechanism. These endpoints are only available when the corresponding plugin is loaded +(see :doc:`/tutorials/linux-introspection`). + +.. warning:: + + The procfs plugin exposes process command lines (``/proc/{pid}/cmdline``) via HTTP. + Command lines may contain sensitive data (API keys, passwords passed as arguments). + Enable authentication when using the procfs plugin in production environments. + +.. note:: + + Vendor extension endpoints are registered dynamically by plugins. They do not appear in + the ``GET /`` root endpoint list. Use entity capability responses (``GET /apps/{id}``, + ``GET /components/{id}``) to discover available extensions via the ``capabilities`` field. + +Linux Process Introspection (x-medkit-procfs) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requires: ``procfs_introspection`` plugin. + +``GET /api/v1/apps/{id}/x-medkit-procfs`` + Get process-level information for a single app. + + **Response 200:** + + .. code-block:: json + + { + "pid": 1234, + "ppid": 1, + "state": "S", + "exe": "/usr/bin/talker", + "cmdline": "/usr/bin/talker --ros-args __node:=talker __ns:=/demo", + "rss_bytes": 524288, + "vm_size_bytes": 2097152, + "threads": 4, + "cpu_user_ticks": 1520, + "cpu_system_ticks": 340, + "cpu_user_seconds": 15.2, + "cpu_system_seconds": 3.4, + "uptime_seconds": 123.45 + } + + - **404:** Process not found (node not running or PID cache miss) + - **503:** Failed to read process information + +``GET /api/v1/components/{id}/x-medkit-procfs`` + Aggregate process info for all apps in the component. Processes are + deduplicated by PID (multiple nodes in the same process appear once). + + **Response 200:** + + .. code-block:: json + + { + "processes": [ + { + "pid": 1234, + "node_ids": ["talker", "listener"], + "...": "same fields as app endpoint" + } + ] + } + +Systemd Unit Introspection (x-medkit-systemd) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requires: ``systemd_introspection`` plugin and ``libsystemd``. + +``GET /api/v1/apps/{id}/x-medkit-systemd`` + Get systemd unit information for the app's process. + + **Response 200:** + + .. code-block:: json + + { + "unit": "ros2-talker.service", + "unit_type": "service", + "active_state": "active", + "sub_state": "running", + "restart_count": 2, + "watchdog_usec": 5000000 + } + + ``restart_count`` and ``watchdog_usec`` are only meaningful for service units. + For other unit types (timer, mount, etc.) they are always 0. + + - **404:** Process not found or not managed by a systemd unit + - **503:** Failed to query systemd properties + +``GET /api/v1/components/{id}/x-medkit-systemd`` + Aggregate systemd unit info for all apps in the component. Units are + deduplicated by unit name. + + **Response 200:** + + .. code-block:: json + + { + "units": [ + { + "unit": "ros2-talker.service", + "node_ids": ["talker", "listener"], + "...": "same fields as app endpoint" + } + ] + } + +Container Introspection (x-medkit-container) +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Requires: ``container_introspection`` plugin. Only supports cgroup v2 +(Ubuntu 22.04+, Fedora 31+). + +``GET /api/v1/apps/{id}/x-medkit-container`` + Get container information for the app's process. + + **Response 200:** + + .. code-block:: json + + { + "container_id": "a1b2c3d4e5f6...", + "runtime": "docker", + "memory_limit_bytes": 1073741824, + "cpu_quota_us": 100000, + "cpu_period_us": 100000 + } + + Fields ``memory_limit_bytes``, ``cpu_quota_us``, and ``cpu_period_us`` are only present + when the container has resource limits configured. + + - **404:** Process not found or not running in a container + - **503:** Failed to read cgroup information + +``GET /api/v1/components/{id}/x-medkit-container`` + Aggregate container info for all apps in the component. Containers are + deduplicated by container ID. + + **Response 200:** + + .. code-block:: json + + { + "containers": [ + { + "container_id": "a1b2c3d4e5f6...", + "node_ids": ["talker", "listener"], + "...": "same fields as app endpoint" + } + ] + } + Error Responses --------------- diff --git a/docs/tutorials/index.rst b/docs/tutorials/index.rst index 091588df5..ef0b94d2a 100644 --- a/docs/tutorials/index.rst +++ b/docs/tutorials/index.rst @@ -21,6 +21,7 @@ Step-by-step guides for common use cases with ros2_medkit. web-ui mcp-server plugin-system + linux-introspection Demos ----- @@ -88,3 +89,6 @@ Advanced Tutorials :doc:`plugin-system` Extend the gateway with custom plugins for update backends, introspection, and REST endpoints. + +:doc:`linux-introspection` + Enrich discovery with Linux process, systemd, and container metadata. diff --git a/docs/tutorials/linux-introspection.rst b/docs/tutorials/linux-introspection.rst new file mode 100644 index 000000000..d2c64229b --- /dev/null +++ b/docs/tutorials/linux-introspection.rst @@ -0,0 +1,391 @@ +Linux Introspection Plugins +=========================== + +The ``ros2_medkit_linux_introspection`` package provides three plugins that enrich the +gateway with OS-level metadata for ROS 2 nodes. Each plugin implements the +``IntrospectionProvider`` interface and registers vendor-specific REST endpoints on +Apps and Components. + +- **procfs** - reads ``/proc`` for process info (PID, RSS, CPU ticks, threads, exe path, + cmdline). Works on any Linux system. +- **systemd** - maps ROS 2 nodes to systemd units via ``sd_pid_get_unit()``, then queries + unit properties (ActiveState, SubState, NRestarts, WatchdogUSec) via sd-bus. Requires + ``libsystemd``. +- **container** - detects containerization via cgroup path analysis. Supports Docker, + podman, and containerd. Reads cgroup v2 resource limits (``memory.max``, ``cpu.max``). + +Each plugin maintains its own PID cache that maps ROS 2 node fully-qualified names to +Linux PIDs by scanning ``/proc``. The cache refreshes on each discovery cycle and on +demand when the TTL expires. + +Requirements +------------ + +- **procfs**: Linux only (reads ``/proc`` filesystem). No extra dependencies. +- **systemd**: requires ``libsystemd-dev`` at build time, systemd at runtime. Skipped + automatically if ``libsystemd`` is not found during the build. +- **container**: requires cgroup v2, which is the default on modern kernels (Ubuntu 22.04+, + Fedora 31+). + +Building +-------- + +The plugins build as part of the ros2_medkit colcon workspace: + +.. code-block:: bash + + source /opt/ros/jazzy/setup.bash + colcon build --packages-select ros2_medkit_linux_introspection + +Verify the ``.so`` files are installed: + +.. code-block:: bash + + ls install/ros2_medkit_linux_introspection/lib/ros2_medkit_linux_introspection/ + # libprocfs_introspection.so + # libsystemd_introspection.so (only if libsystemd was found) + # libcontainer_introspection.so + +.. note:: + + The systemd plugin is conditionally built. If ``libsystemd-dev`` is not installed, + CMake prints a warning and skips it. Install with ``sudo apt install libsystemd-dev`` + on Ubuntu/Debian. + +Configuration +------------- + +Add plugins to ``gateway_params.yaml``. You can enable any combination - each plugin +is independent: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + plugins: ["procfs", "systemd", "container"] + # Paths are relative to the colcon workspace root (where you run 'ros2 launch'). + # Use absolute paths if launching from a different directory. + plugins.procfs.path: "install/ros2_medkit_linux_introspection/lib/ros2_medkit_linux_introspection/libprocfs_introspection.so" + plugins.procfs.pid_cache_ttl_seconds: 10 + plugins.systemd.path: "install/ros2_medkit_linux_introspection/lib/ros2_medkit_linux_introspection/libsystemd_introspection.so" + plugins.systemd.pid_cache_ttl_seconds: 10 + plugins.container.path: "install/ros2_medkit_linux_introspection/lib/ros2_medkit_linux_introspection/libcontainer_introspection.so" + plugins.container.pid_cache_ttl_seconds: 10 + +Or enable just one plugin: + +.. code-block:: yaml + + ros2_medkit_gateway: + ros__parameters: + plugins: ["procfs"] + plugins.procfs.path: "/opt/ros2_medkit/lib/ros2_medkit_linux_introspection/libprocfs_introspection.so" + +Configuration parameters (all plugins): + +``pid_cache_ttl_seconds`` (int, default 10) + TTL in seconds for the PID cache. The cache maps ROS 2 node FQNs to PIDs by scanning + ``/proc``. Lower values give fresher data but increase ``/proc`` scan frequency. + +``proc_root`` (string, default ``"/"``) + Root path for ``/proc`` access. Primarily used for testing with synthetic ``/proc`` + trees. In production, leave at the default. + +See :doc:`/tutorials/plugin-system` for general plugin configuration details. + +API Reference +------------- + +Each plugin registers vendor-specific endpoints on Apps (individual nodes) and Components +(aggregated across child nodes). + +procfs Endpoints +~~~~~~~~~~~~~~~~ + +**GET /apps/{id}/x-medkit-procfs** + +Returns process-level metrics for a single ROS 2 node: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-procfs | jq + +.. code-block:: json + + { + "pid": 12345, + "ppid": 1, + "exe": "/opt/ros/jazzy/lib/demo_nodes_cpp/talker", + "cmdline": "/opt/ros/jazzy/lib/demo_nodes_cpp/talker --ros-args ...", + "rss_bytes": 15728640, + "vm_size_bytes": 268435456, + "threads": 4, + "cpu_user_ticks": 1500, + "cpu_system_ticks": 300, + "uptime_seconds": 3600 + } + +**GET /components/{id}/x-medkit-procfs** + +Returns aggregated process info for all child Apps of a Component, deduplicated by PID. +Each entry includes a ``node_ids`` array listing the Apps that share the process: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-procfs | jq + +.. code-block:: json + + { + "processes": [ + { + "pid": 12345, + "ppid": 1, + "exe": "/opt/ros/jazzy/lib/sensor_pkg/sensor_node", + "cmdline": "/opt/ros/jazzy/lib/sensor_pkg/sensor_node --ros-args ...", + "rss_bytes": 15728640, + "vm_size_bytes": 268435456, + "threads": 4, + "cpu_user_ticks": 1500, + "cpu_system_ticks": 300, + "uptime_seconds": 3600, + "node_ids": ["temp_sensor", "rpm_sensor"] + } + ] + } + +systemd Endpoints +~~~~~~~~~~~~~~~~~ + +**GET /apps/{id}/x-medkit-systemd** + +Returns the systemd unit managing the node's process: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-systemd | jq + +.. code-block:: json + + { + "unit": "ros2-demo-temp-sensor.service", + "unit_type": "service", + "active_state": "active", + "sub_state": "running", + "restart_count": 0, + "watchdog_usec": 0 + } + +**GET /components/{id}/x-medkit-systemd** + +Returns aggregated unit info for all child Apps, deduplicated by unit name: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-systemd | jq + +.. code-block:: json + + { + "units": [ + { + "unit": "ros2-demo.service", + "unit_type": "service", + "active_state": "active", + "sub_state": "running", + "restart_count": 0, + "watchdog_usec": 0, + "node_ids": ["temp_sensor", "rpm_sensor"] + } + ] + } + +container Endpoints +~~~~~~~~~~~~~~~~~~~ + +**GET /apps/{id}/x-medkit-container** + +Returns container metadata for a node running inside a container: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/apps/temp_sensor/x-medkit-container | jq + +.. code-block:: json + + { + "container_id": "a1b2c3d4e5f6...", + "runtime": "docker", + "memory_limit_bytes": 536870912, + "cpu_quota_us": 100000, + "cpu_period_us": 100000 + } + +.. note:: + + The ``memory_limit_bytes``, ``cpu_quota_us``, and ``cpu_period_us`` fields are only + present when cgroup v2 resource limits are set. If no limits are configured, these + fields are omitted from the response. + +**GET /components/{id}/x-medkit-container** + +Returns aggregated container info for all child Apps, deduplicated by container ID: + +.. code-block:: bash + + curl http://localhost:8080/api/v1/components/sensor_suite/x-medkit-container | jq + +.. code-block:: json + + { + "containers": [ + { + "container_id": "a1b2c3d4e5f6...", + "runtime": "docker", + "memory_limit_bytes": 536870912, + "cpu_quota_us": 100000, + "cpu_period_us": 100000, + "node_ids": ["temp_sensor", "rpm_sensor"] + } + ] + } + +Error Responses +--------------- + +All endpoints return SOVD-compliant ``GenericError`` responses on failure. Entity +validation errors (404 for unknown entities) are handled automatically by +``validate_entity_for_route()``. Plugin-specific errors: + ++-----+---------------------------------------+-----------------------------------------------+ +| Code| Error ID | Description | ++=====+=======================================+===============================================+ +| 404 | ``x-medkit-pid-lookup-failed`` | PID not found for node. The node may not be | +| | | running, or the PID cache has not refreshed. | ++-----+---------------------------------------+-----------------------------------------------+ +| 503 | ``x-medkit-proc-read-failed`` | Failed to read ``/proc/{pid}`` info. Process | +| | | may have exited between PID lookup and read. | ++-----+---------------------------------------+-----------------------------------------------+ +| 404 | ``x-medkit-not-in-systemd-unit`` | Node's process is not managed by a systemd | +| | | unit. It may have been started manually. | ++-----+---------------------------------------+-----------------------------------------------+ +| 503 | ``x-medkit-systemd-query-failed`` | Failed to query systemd properties via sd-bus.| +| | | Check D-Bus socket access. | ++-----+---------------------------------------+-----------------------------------------------+ +| 404 | ``x-medkit-not-containerized`` | Node's process is not running inside a | +| | | container (no container cgroup path detected).| ++-----+---------------------------------------+-----------------------------------------------+ +| 503 | ``x-medkit-cgroup-read-failed`` | Failed to read cgroup info for the container. | +| | | Check cgroup v2 filesystem access. | ++-----+---------------------------------------+-----------------------------------------------+ + +.. note:: + + Component-level endpoints (``/components/{id}/x-medkit-*``) silently skip child Apps + that cannot be resolved. They return partial results rather than failing entirely. + +Composable Nodes +---------------- + +When multiple ROS 2 nodes share a process (composable nodes / component containers), +they share the same PID. The plugins handle this correctly: + +- **procfs**: the Component endpoint deduplicates by PID. A single process entry + includes all node IDs that share it in the ``node_ids`` array. +- **systemd**: the Component endpoint deduplicates by unit name. Composable nodes in + the same process always map to the same systemd unit. +- **container**: the Component endpoint deduplicates by container ID. All nodes sharing + a container appear in one entry. + +App-level endpoints always return data for the single process hosting that node, +regardless of how many other nodes share the same process. + +Introspection Metadata +---------------------- + +Plugin introspection data is accessed via the vendor extension endpoints registered by +each plugin (e.g., ``GET /apps/{id}/x-medkit-procfs``). The ``IntrospectionProvider`` +interface enriches the discovery pipeline with capabilities and metadata fields, but the +detailed introspection data is served through the plugin's own HTTP routes rather than +embedded in standard discovery responses. + +Troubleshooting +--------------- + +PID lookup failures +~~~~~~~~~~~~~~~~~~~ + +The PID cache refreshes when its TTL expires (default 10 seconds). If a node was just +started, the cache may not have picked it up yet. Causes: + +- Node started after the last cache refresh. Wait for the next refresh cycle. +- Node name mismatch. The PID cache matches ROS 2 node FQNs (e.g., ``/sensors/temp``) + against ``/proc/{pid}/cmdline`` entries. Ensure the node's ``--ros-args -r __node:=`` + and ``-r __ns:=`` match expectations. +- Node exited. The process may have crashed between the cache refresh and the REST + request. + +Composable nodes +~~~~~~~~~~~~~~~~ + +Composable nodes loaded via ``ros2 component load`` into a component container do not +have ``__node:=`` or ``__ns:=`` arguments in their ``/proc/{pid}/cmdline``. Node names +are set programmatically via ``rclcpp::NodeOptions`` rather than through command-line +arguments. As a result, the PID cache cannot resolve these nodes and they will appear +as unreachable in all introspection endpoints. + +**Workaround**: Launch composable nodes via ``ros2 launch`` with explicit remapping +arguments (``--ros-args -r __node:= -r __ns:=``) instead of loading +them dynamically with ``ros2 component load``. + +Permission errors (procfs) +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Most ``/proc/{pid}`` files are world-readable. However: + +- ``/proc/{pid}/exe`` (symlink to executable) requires same-user access or + ``CAP_SYS_PTRACE``. If the gateway runs as a different user, the ``exe`` field may + be empty. +- In hardened environments with ``hidepid=2`` mount option on ``/proc``, only processes + owned by the same user are visible. Run the gateway as root or in the same user + namespace. + +systemd bus access +~~~~~~~~~~~~~~~~~~ + +The systemd plugin uses ``sd_bus_open_system()`` to connect to the system bus, typically +via ``/run/dbus/system_bus_socket``. If the gateway runs in a container: + +.. code-block:: bash + + # Mount the host's D-Bus socket into the container + docker run -v /run/dbus/system_bus_socket:/run/dbus/system_bus_socket ... + + # Or run privileged (not recommended for production) + docker run --privileged ... + +Without system bus access, the systemd plugin will return 503 errors for all queries. + +Container detection +~~~~~~~~~~~~~~~~~~~ + +The container plugin relies on cgroup v2 path analysis. To verify your system uses +cgroup v2: + +.. code-block:: bash + + mount | grep cgroup2 + # Should show: cgroup2 on /sys/fs/cgroup type cgroup2 (...) + + # Or check a process's cgroup path + cat /proc/self/cgroup + # cgroup v2 output: "0::/user.slice/..." + +Supported container runtimes and their cgroup path patterns: + +- **Docker**: ``/docker/<64-char-hex>`` +- **podman**: ``/libpod-<64-char-hex>.scope`` +- **containerd** (CRI): ``/cri-containerd-<64-char-hex>.scope`` + +If your runtime uses a different cgroup path format, the plugin will not detect the +container. The ``runtime`` field in the response indicates the detected runtime. diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/CMakeLists.txt b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/CMakeLists.txt new file mode 100644 index 000000000..f82431c40 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/CMakeLists.txt @@ -0,0 +1,102 @@ +cmake_minimum_required(VERSION 3.8) +project(ros2_medkit_linux_introspection) + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_EXPORT_COMPILE_COMMANDS ON) + +# Shared cmake modules (3 levels up from src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/) +list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/../../../cmake") +include(ROS2MedkitCompat) +include(ROS2MedkitCcache) +include(ROS2MedkitLinting) + +if(CMAKE_COMPILER_IS_GNUCXX OR CMAKE_CXX_COMPILER_ID MATCHES "Clang") + add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) +endif() + +find_package(ament_cmake REQUIRED) +find_package(ros2_medkit_gateway REQUIRED) +find_package(nlohmann_json REQUIRED) +medkit_find_cpp_httplib() # From ROS2MedkitCompat - handles Humble/Jazzy/Rolling differences +find_package(OpenSSL REQUIRED) +add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) # Must match gateway's definition for ODR safety + +# --- Static utility library --- +add_library(medkit_linux_utils STATIC + src/linux_utils/proc_reader.cpp + src/linux_utils/cgroup_reader.cpp +) +set_target_properties(medkit_linux_utils PROPERTIES POSITION_INDEPENDENT_CODE ON) +target_include_directories(medkit_linux_utils PUBLIC + $ +) +target_include_directories(medkit_linux_utils PUBLIC ${ros2_medkit_gateway_INCLUDE_DIRS}) + +# --- procfs plugin --- +add_library(procfs_introspection MODULE + src/procfs_plugin.cpp +) +target_link_libraries(procfs_introspection medkit_linux_utils cpp_httplib_target OpenSSL::SSL OpenSSL::Crypto) +target_include_directories(procfs_introspection PRIVATE ${ros2_medkit_gateway_INCLUDE_DIRS}) +medkit_target_dependencies(procfs_introspection nlohmann_json) +install(TARGETS procfs_introspection LIBRARY DESTINATION lib/${PROJECT_NAME}) + +# --- systemd plugin --- +find_package(PkgConfig REQUIRED) +pkg_check_modules(SYSTEMD libsystemd) + +if(SYSTEMD_FOUND) + add_library(systemd_introspection MODULE + src/systemd_plugin.cpp + ) + target_link_libraries(systemd_introspection medkit_linux_utils ${SYSTEMD_LIBRARIES} cpp_httplib_target OpenSSL::SSL OpenSSL::Crypto) + target_include_directories(systemd_introspection PRIVATE ${SYSTEMD_INCLUDE_DIRS} ${ros2_medkit_gateway_INCLUDE_DIRS}) + medkit_target_dependencies(systemd_introspection nlohmann_json) + install(TARGETS systemd_introspection LIBRARY DESTINATION lib/${PROJECT_NAME}) +else() + message(WARNING "libsystemd not found - systemd_introspection plugin will not be built") +endif() + +# --- container plugin --- +add_library(container_introspection MODULE + src/container_plugin.cpp +) +target_link_libraries(container_introspection medkit_linux_utils cpp_httplib_target OpenSSL::SSL OpenSSL::Crypto) +target_include_directories(container_introspection PRIVATE ${ros2_medkit_gateway_INCLUDE_DIRS}) +medkit_target_dependencies(container_introspection nlohmann_json) +install(TARGETS container_introspection LIBRARY DESTINATION lib/${PROJECT_NAME}) + +# --- Tests --- +if(BUILD_TESTING) + find_package(ament_lint_auto REQUIRED) + find_package(ament_cmake_gtest REQUIRED) + + ament_lint_auto_find_test_dependencies() + + ament_add_gtest(test_proc_reader test/test_proc_reader.cpp) + target_link_libraries(test_proc_reader medkit_linux_utils) + + ament_add_gtest(test_cgroup_reader test/test_cgroup_reader.cpp) + target_link_libraries(test_cgroup_reader medkit_linux_utils) + + ament_add_gtest(test_pid_cache test/test_pid_cache.cpp) + target_link_libraries(test_pid_cache medkit_linux_utils) + + ament_add_gtest(test_procfs_plugin test/test_procfs_plugin.cpp) + target_link_libraries(test_procfs_plugin medkit_linux_utils cpp_httplib_target) + target_include_directories(test_procfs_plugin PRIVATE ${ros2_medkit_gateway_INCLUDE_DIRS}) + medkit_target_dependencies(test_procfs_plugin nlohmann_json) + + ament_add_gtest(test_container_plugin test/test_container_plugin.cpp) + target_link_libraries(test_container_plugin medkit_linux_utils cpp_httplib_target) + target_include_directories(test_container_plugin PRIVATE ${ros2_medkit_gateway_INCLUDE_DIRS}) + medkit_target_dependencies(test_container_plugin nlohmann_json) + + ament_add_gtest(test_systemd_plugin test/test_systemd_plugin.cpp) + target_link_libraries(test_systemd_plugin medkit_linux_utils) + target_include_directories(test_systemd_plugin PRIVATE ${ros2_medkit_gateway_INCLUDE_DIRS}) + medkit_target_dependencies(test_systemd_plugin nlohmann_json) +endif() + +ament_package() diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/cgroup_reader.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/cgroup_reader.hpp new file mode 100644 index 000000000..b560f3a24 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/cgroup_reader.hpp @@ -0,0 +1,48 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include + +namespace ros2_medkit_linux_introspection { + +struct CgroupInfo { + std::string cgroup_path; + std::string container_id; // 64-char hex from cgroup path, or empty + std::string container_runtime; // "docker", "podman", "containerd", or empty + std::optional memory_limit_bytes; + std::optional cpu_quota_us; + std::optional cpu_period_us; +}; + +/// Detect if PID runs inside a container (based on cgroup path) +bool is_containerized(pid_t pid, const std::string & root = "/"); + +/// Read cgroup info for a PID +tl::expected read_cgroup_info(pid_t pid, const std::string & root = "/"); + +/// Extract container ID from a cgroup path string +/// Supports Docker (/docker/), podman (/libpod-.scope), containerd +/// (/cri-containerd-.scope) +std::string extract_container_id(const std::string & cgroup_path); + +/// Detect container runtime from cgroup path +std::string detect_runtime(const std::string & cgroup_path); + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/container_utils.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/container_utils.hpp new file mode 100644 index 000000000..99d9ea9ff --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/container_utils.hpp @@ -0,0 +1,42 @@ +// 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_linux_introspection/cgroup_reader.hpp" + +#include + +namespace ros2_medkit_linux_introspection { + +/// Convert CgroupInfo to JSON for the container plugin HTTP response. +/// Optional fields (memory_limit_bytes, cpu_quota_us, cpu_period_us) are +/// omitted from the JSON when not set. +inline nlohmann::json cgroup_info_to_json(const CgroupInfo & info) { + nlohmann::json j; + j["container_id"] = info.container_id; + j["runtime"] = info.container_runtime; + if (info.memory_limit_bytes) { + j["memory_limit_bytes"] = *info.memory_limit_bytes; + } + if (info.cpu_quota_us) { + j["cpu_quota_us"] = *info.cpu_quota_us; + } + if (info.cpu_period_us) { + j["cpu_period_us"] = *info.cpu_period_us; + } + return j; +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/plugin_config.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/plugin_config.hpp new file mode 100644 index 000000000..c666dfcc3 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/plugin_config.hpp @@ -0,0 +1,54 @@ +// 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_linux_introspection/proc_reader.hpp" + +#include + +#include +#include +#include + +namespace ros2_medkit_linux_introspection { + +struct IntrospectionConfig { + std::unique_ptr pid_cache; + std::string proc_root{"/"}; +}; + +inline IntrospectionConfig parse_introspection_config(const nlohmann::json & config) { + IntrospectionConfig result; + + std::chrono::seconds ttl{10}; + if (config.contains("pid_cache_ttl_seconds")) { + auto val = config["pid_cache_ttl_seconds"].get(); + if (val < 1) { + val = 1; + } + ttl = std::chrono::seconds{val}; + } + if (config.contains("proc_root")) { + result.proc_root = config["proc_root"].get(); + if (result.proc_root.empty() || result.proc_root[0] != '/') { + result.proc_root = "/"; + } + } + result.pid_cache = std::make_unique(ttl); + + return result; +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/proc_reader.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/proc_reader.hpp new file mode 100644 index 000000000..9d8c4c795 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/proc_reader.hpp @@ -0,0 +1,73 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#pragma once + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace ros2_medkit_linux_introspection { + +struct ProcessInfo { + pid_t pid{0}; + pid_t ppid{0}; + std::string state; // Process state: R=running, S=sleeping, D=disk, Z=zombie, T=stopped + std::string cmdline; + std::string exe_path; + uint64_t rss_bytes{0}; + uint64_t vm_size_bytes{0}; + uint64_t cpu_user_ticks{0}; + uint64_t cpu_system_ticks{0}; + uint64_t start_time_ticks{0}; + uint32_t num_threads{0}; +}; + +/// Read process info from /proc/{pid} +tl::expected read_process_info(pid_t pid, const std::string & root = "/"); + +/// Scan /proc for ROS 2 node processes, return PID for matching node +tl::expected find_pid_for_node(const std::string & node_name, const std::string & node_namespace, + const std::string & root = "/"); + +/// Read system uptime in seconds from /proc/uptime +tl::expected read_system_uptime(const std::string & root = "/"); + +/// Cache for node-to-PID mappings with TTL-based refresh +class PidCache { + public: + explicit PidCache(std::chrono::steady_clock::duration ttl = std::chrono::seconds{10}); + + /// Lookup PID for a node FQN (e.g. "/namespace/node_name"). Refreshes if TTL expired. + std::optional lookup(const std::string & node_fqn, const std::string & root = "/"); + + /// Force refresh the cache by rescanning /proc + void refresh(const std::string & root = "/"); + + /// Number of cached entries + size_t size() const; + + private: + std::unordered_map node_to_pid_; + std::chrono::steady_clock::time_point last_refresh_; + std::chrono::steady_clock::duration ttl_; + mutable std::shared_mutex mutex_; +}; + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/procfs_utils.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/procfs_utils.hpp new file mode 100644 index 000000000..b3bb59992 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/procfs_utils.hpp @@ -0,0 +1,62 @@ +// 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_linux_introspection/proc_reader.hpp" + +#include + +#include + +namespace ros2_medkit_linux_introspection { + +/// Convert ProcessInfo to JSON for the procfs plugin HTTP response. +/// @param info Process information from read_process_info() +/// @param system_uptime System uptime in seconds from read_system_uptime() +/// @return JSON object with all process fields +inline nlohmann::json process_info_to_json(const ProcessInfo & info, double system_uptime) { + long ticks_per_sec = sysconf(_SC_CLK_TCK); + double uptime_sec = 0.0; + if (ticks_per_sec > 0 && info.start_time_ticks > 0 && system_uptime > 0.0) { + double start_sec = static_cast(info.start_time_ticks) / static_cast(ticks_per_sec); + uptime_sec = system_uptime - start_sec; + if (uptime_sec < 0.0) { + uptime_sec = 0.0; + } + } + + double cpu_user_sec = 0.0; + double cpu_system_sec = 0.0; + if (ticks_per_sec > 0) { + cpu_user_sec = static_cast(info.cpu_user_ticks) / static_cast(ticks_per_sec); + cpu_system_sec = static_cast(info.cpu_system_ticks) / static_cast(ticks_per_sec); + } + + return {{"pid", info.pid}, + {"ppid", info.ppid}, + {"state", info.state}, + {"exe", info.exe_path}, + {"cmdline", info.cmdline}, + {"rss_bytes", info.rss_bytes}, + {"vm_size_bytes", info.vm_size_bytes}, + {"threads", info.num_threads}, + {"cpu_user_ticks", info.cpu_user_ticks}, + {"cpu_system_ticks", info.cpu_system_ticks}, + {"cpu_user_seconds", cpu_user_sec}, + {"cpu_system_seconds", cpu_system_sec}, + {"uptime_seconds", uptime_sec}}; +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/systemd_utils.hpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/systemd_utils.hpp new file mode 100644 index 000000000..f74d542ea --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/include/ros2_medkit_linux_introspection/systemd_utils.hpp @@ -0,0 +1,39 @@ +// 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 + +namespace ros2_medkit_linux_introspection { + +/// Escape a systemd unit name for use in D-Bus object paths. +/// Non-alphanumeric characters (except underscore) are hex-encoded as _XX. +inline std::string escape_unit_for_dbus(const std::string & unit) { + std::string result; + result.reserve(unit.size()); + for (char c : unit) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_') { + result += c; + } else { + char buf[8]; + snprintf(buf, sizeof(buf), "_%02x", static_cast(c)); + result += buf; + } + } + return result; +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/package.xml b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/package.xml new file mode 100644 index 000000000..da401c786 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/package.xml @@ -0,0 +1,24 @@ + + + + ros2_medkit_linux_introspection + 0.1.0 + Linux introspection plugins for ros2_medkit gateway - procfs, systemd, and container + bburda + Apache-2.0 + + ament_cmake + + ros2_medkit_gateway + nlohmann-json-dev + libcpp-httplib-dev + libssl-dev + libsystemd-dev + + ament_lint_auto + ament_cmake_gtest + + + ament_cmake + + diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/container_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/container_plugin.cpp new file mode 100644 index 000000000..3f16110ce --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/container_plugin.cpp @@ -0,0 +1,169 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp" +#include "ros2_medkit_gateway/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/providers/introspection_provider.hpp" +#include "ros2_medkit_linux_introspection/cgroup_reader.hpp" +#include "ros2_medkit_linux_introspection/container_utils.hpp" +#include "ros2_medkit_linux_introspection/plugin_config.hpp" +#include "ros2_medkit_linux_introspection/proc_reader.hpp" + +#include +#include + +#include +#include +#include + +using namespace ros2_medkit_gateway; // NOLINT(build/namespaces) + +class ContainerPlugin : public GatewayPlugin, public IntrospectionProvider { + public: + std::string name() const override { + return "container_introspection"; + } + + void configure(const nlohmann::json & config) override { + auto cfg = ros2_medkit_linux_introspection::parse_introspection_config(config); + pid_cache_ = std::move(cfg.pid_cache); + proc_root_ = std::move(cfg.proc_root); + } + + void set_context(PluginContext & ctx) override { + ctx_ = &ctx; + ctx.register_capability(SovdEntityType::APP, "x-medkit-container"); + ctx.register_capability(SovdEntityType::COMPONENT, "x-medkit-container"); + } + + void register_routes(httplib::Server & server, const std::string & api_prefix) override { + server.Get((api_prefix + R"(/apps/([^/]+)/x-medkit-container)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_app_request(req, res); + }); + server.Get((api_prefix + R"(/components/([^/]+)/x-medkit-container)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_component_request(req, res); + }); + } + + IntrospectionResult introspect(const IntrospectionInput & input) override { + IntrospectionResult result; + pid_cache_->refresh(proc_root_); + + for (const auto & app : input.apps) { + auto fqn = app.effective_fqn(); + if (fqn.empty()) { + continue; + } + + auto pid_opt = pid_cache_->lookup(fqn, proc_root_); + if (!pid_opt) { + continue; + } + + auto cgroup_info = ros2_medkit_linux_introspection::read_cgroup_info(*pid_opt, proc_root_); + if (!cgroup_info || cgroup_info->container_id.empty()) { + continue; + } + + result.metadata[app.id] = ros2_medkit_linux_introspection::cgroup_info_to_json(*cgroup_info); + } + return result; + } + + private: + PluginContext * ctx_{nullptr}; + std::unique_ptr pid_cache_ = + std::make_unique(); + std::string proc_root_{"/"}; + + void handle_app_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto pid_opt = pid_cache_->lookup(entity->fqn, proc_root_); + if (!pid_opt) { + PluginContext::send_error(res, 404, "x-medkit-pid-lookup-failed", "Process not found for entity " + entity_id); + return; + } + + auto cgroup_info = ros2_medkit_linux_introspection::read_cgroup_info(*pid_opt, proc_root_); + if (!cgroup_info) { + PluginContext::send_error(res, 503, "x-medkit-cgroup-read-failed", + "Failed to read cgroup information for entity " + entity_id); + return; + } + + if (cgroup_info->container_id.empty()) { + PluginContext::send_error(res, 404, "x-medkit-not-containerized", + "Entity " + entity_id + " is not running in a container"); + return; + } + + PluginContext::send_json(res, ros2_medkit_linux_introspection::cgroup_info_to_json(*cgroup_info)); + } + + void handle_component_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto child_apps = ctx_->get_child_apps(entity_id); + std::map containers; // Deduplicate by container_id + + for (const auto & app : child_apps) { + auto pid_opt = pid_cache_->lookup(app.fqn, proc_root_); + if (!pid_opt) { + continue; + } + + auto cgroup_info = ros2_medkit_linux_introspection::read_cgroup_info(*pid_opt, proc_root_); + if (!cgroup_info || cgroup_info->container_id.empty()) { + continue; + } + + auto & cid = cgroup_info->container_id; + if (containers.find(cid) == containers.end()) { + auto j = ros2_medkit_linux_introspection::cgroup_info_to_json(*cgroup_info); + j["node_ids"] = nlohmann::json::array(); + containers[cid] = std::move(j); + } + containers[cid]["node_ids"].push_back(app.id); + } + + nlohmann::json result; + result["containers"] = nlohmann::json::array(); + for (auto & [_, container_json] : containers) { + result["containers"].push_back(std::move(container_json)); + } + PluginContext::send_json(res, result); + } +}; + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return PLUGIN_API_VERSION; +} +extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { + return new ContainerPlugin(); +} +extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * p) { + return static_cast(p); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/cgroup_reader.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/cgroup_reader.cpp new file mode 100644 index 000000000..a3e402533 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/cgroup_reader.cpp @@ -0,0 +1,129 @@ +// 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_linux_introspection/cgroup_reader.hpp" + +#include +#include +#include +#include + +namespace ros2_medkit_linux_introspection { + +namespace { + +std::string read_first_line(const std::string & path) { + std::ifstream f(path); + std::string line; + if (f.is_open()) { + std::getline(f, line); + } + return line; +} + +// Read cgroup path from /proc/{pid}/cgroup (cgroup v2: single line "0::") +std::string read_cgroup_path(pid_t pid, const std::string & root) { + auto path = root + "/proc/" + std::to_string(pid) + "/cgroup"; + std::ifstream f(path); + std::string line; + while (std::getline(f, line)) { + // cgroup v2 format: "0::" + if (line.rfind("0::", 0) == 0) { + return line.substr(3); + } + } + return {}; +} + +} // namespace + +std::string extract_container_id(const std::string & cgroup_path) { + // Docker: /docker-<64hex>.scope or /docker/<64hex> + // Podman: /libpod-<64hex>.scope + // Containerd: /cri-containerd-<64hex>.scope + static const std::regex re("(?:docker-|libpod-|cri-containerd-|docker/)([0-9a-f]{64})(?:\\.scope)?"); + std::smatch match; + if (std::regex_search(cgroup_path, match, re)) { + return match[1].str(); + } + return {}; +} + +std::string detect_runtime(const std::string & cgroup_path) { + if (cgroup_path.find("docker") != std::string::npos) { + return "docker"; + } + if (cgroup_path.find("libpod") != std::string::npos) { + return "podman"; + } + if (cgroup_path.find("cri-containerd") != std::string::npos) { + return "containerd"; + } + return {}; +} + +bool is_containerized(pid_t pid, const std::string & root) { + auto cgroup_path = read_cgroup_path(pid, root); + return !extract_container_id(cgroup_path).empty(); +} + +tl::expected read_cgroup_info(pid_t pid, const std::string & root) { + auto cgroup_path = read_cgroup_path(pid, root); + if (cgroup_path.empty()) { + return tl::make_unexpected("Cannot read cgroup for PID " + std::to_string(pid)); + } + + CgroupInfo info; + info.cgroup_path = cgroup_path; + info.container_id = extract_container_id(cgroup_path); + info.container_runtime = detect_runtime(cgroup_path); + + // Read resource limits from cgroup v2 filesystem + auto cgroup_fs_path = root + "/sys/fs/cgroup" + cgroup_path; + + // memory.max + auto mem_max = read_first_line(cgroup_fs_path + "/memory.max"); + if (!mem_max.empty() && mem_max != "max") { + try { + info.memory_limit_bytes = std::stoull(mem_max); + } catch (const std::exception & e) { + std::cerr << "[ros2_medkit_linux_introspection] Failed to parse memory.max value '" << mem_max + << "': " << e.what() << std::endl; + } + } + + // cpu.max (format: "quota period" or "max period") + auto cpu_max = read_first_line(cgroup_fs_path + "/cpu.max"); + if (!cpu_max.empty()) { + std::istringstream ss(cpu_max); + std::string quota_str; + int64_t period = 0; + ss >> quota_str >> period; + if (quota_str != "max") { + try { + info.cpu_quota_us = std::stoll(quota_str); + } catch (const std::exception & e) { + std::cerr << "[ros2_medkit_linux_introspection] Failed to parse cpu.max quota '" << quota_str + << "': " << e.what() << std::endl; + } + } + if (period > 0) { + info.cpu_period_us = period; + } + } + + return info; +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/proc_reader.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/proc_reader.cpp new file mode 100644 index 000000000..64c324627 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/linux_utils/proc_reader.cpp @@ -0,0 +1,363 @@ +// 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_linux_introspection/proc_reader.hpp" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace ros2_medkit_linux_introspection { + +namespace { + +struct DirCloser { + void operator()(DIR * d) const { + if (d) { + closedir(d); + } + } +}; +using UniqueDir = std::unique_ptr; + +std::string read_file_contents(const std::string & path) { + std::ifstream f(path); + if (!f.is_open()) { + return {}; + } + return {std::istreambuf_iterator(f), std::istreambuf_iterator()}; +} + +// Parse VmRSS and VmSize from /proc/{pid}/status +void parse_status_file(const std::string & path, uint64_t & rss_bytes, uint64_t & vm_size_bytes) { + std::ifstream f(path); + std::string line; + while (std::getline(f, line)) { + if (line.rfind("VmRSS:", 0) == 0) { + uint64_t val = 0; + if (sscanf(line.c_str(), "VmRSS: %" SCNu64, &val) == 1) { // NOLINT(runtime/printf) + rss_bytes = val * 1024; // kB to bytes + } + } else if (line.rfind("VmSize:", 0) == 0) { + uint64_t val = 0; + if (sscanf(line.c_str(), "VmSize: %" SCNu64, &val) == 1) { // NOLINT(runtime/printf) + vm_size_bytes = val * 1024; + } + } + } +} + +// Parse cmdline from /proc/{pid}/cmdline (null-separated arguments) +std::string parse_cmdline(const std::string & path) { + auto content = read_file_contents(path); + // Replace null bytes with spaces for display + std::replace(content.begin(), content.end(), '\0', ' '); + // Trim trailing space + if (!content.empty() && content.back() == ' ') { + content.pop_back(); + } + return content; +} + +} // namespace + +// Parse node name and namespace from /proc/{pid}/cmdline +// Not in anonymous namespace - used by both find_pid_for_node and PidCache::refresh +static bool parse_ros_args(const std::string & cmdline_path, std::string & node_name, std::string & node_namespace) { + auto content = read_file_contents(cmdline_path); + // cmdline is null-separated + std::vector args; + std::string current; + for (char c : content) { + if (c == '\0') { + if (!current.empty()) { + args.push_back(std::move(current)); + } + current.clear(); + } else { + current += c; + } + } + if (!current.empty()) { + args.push_back(std::move(current)); + } + + for (const auto & arg : args) { + if (arg.rfind("__node:=", 0) == 0) { + node_name = arg.substr(8); + } else if (arg.rfind("__ns:=", 0) == 0) { + node_namespace = arg.substr(6); + } + } + return !node_name.empty(); +} + +tl::expected read_process_info(pid_t pid, const std::string & root) { + auto proc_dir = root + "/proc/" + std::to_string(pid); + + if (!fs::exists(proc_dir)) { + return tl::make_unexpected("Process " + std::to_string(pid) + " not found"); + } + + ProcessInfo info; + info.pid = pid; + + // Parse /proc/{pid}/stat + auto stat_content = read_file_contents(proc_dir + "/stat"); + if (stat_content.empty()) { + return tl::make_unexpected("Cannot read " + proc_dir + "/stat"); + } + + // Find the closing paren of (comm) to skip process names with spaces + auto comm_end = stat_content.rfind(')'); + if (comm_end == std::string::npos || comm_end + 2 >= stat_content.size()) { + return tl::make_unexpected("Malformed stat file for PID " + std::to_string(pid)); + } + + // Fields after (comm): state ppid pgrp session tty_nr tpgid flags + // minflt cminflt majflt cmajflt utime stime cutime cstime priority nice + // num_threads itrealvalue starttime vsize rss ... + std::istringstream ss(stat_content.substr(comm_end + 2)); + std::string state; + int64_t ppid = 0; + int64_t pgrp = 0; + int64_t session = 0; + int64_t tty_nr = 0; + int64_t tpgid = 0; + uint64_t flags = 0; + uint64_t minflt = 0; + uint64_t cminflt = 0; + uint64_t majflt = 0; + uint64_t cmajflt = 0; + uint64_t utime = 0; + uint64_t stime = 0; + uint64_t cutime = 0; + uint64_t cstime = 0; + int64_t priority = 0; + int64_t nice = 0; + uint32_t num_threads = 0; + int64_t itrealvalue = 0; + uint64_t starttime = 0; + + ss >> state >> ppid >> pgrp >> session >> tty_nr >> tpgid >> flags; + ss >> minflt >> cminflt >> majflt >> cmajflt; + ss >> utime >> stime >> cutime >> cstime; + ss >> priority >> nice >> num_threads >> itrealvalue >> starttime; + + if (ss.fail()) { + return tl::make_unexpected("Truncated stat file for PID " + std::to_string(pid)); + } + + // Suppress unused variable warnings for positional fields we don't need + (void)pgrp; + (void)session; + (void)tty_nr; + (void)tpgid; + (void)flags; + (void)minflt; + (void)cminflt; + (void)majflt; + (void)cmajflt; + (void)cutime; + (void)cstime; + (void)priority; + (void)nice; + (void)itrealvalue; + + info.ppid = static_cast(ppid); + info.state = state; + info.cpu_user_ticks = utime; + info.cpu_system_ticks = stime; + info.num_threads = num_threads; + info.start_time_ticks = starttime; + + // Parse /proc/{pid}/status for VmRSS, VmSize + parse_status_file(proc_dir + "/status", info.rss_bytes, info.vm_size_bytes); + + // Parse /proc/{pid}/cmdline + info.cmdline = parse_cmdline(proc_dir + "/cmdline"); + + // Read /proc/{pid}/exe symlink + std::error_code ec; + auto exe = fs::read_symlink(proc_dir + "/exe", ec); + if (!ec) { + info.exe_path = exe.string(); + } + + return info; +} + +tl::expected find_pid_for_node(const std::string & node_name, const std::string & node_namespace, + const std::string & root) { + auto proc_dir = root + "/proc"; + UniqueDir dir(opendir(proc_dir.c_str())); + if (!dir) { + return tl::make_unexpected("Cannot open " + proc_dir); + } + + struct dirent * entry = nullptr; + while ((entry = readdir(dir.get())) != nullptr) { + if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { + continue; + } + if (entry->d_type == DT_UNKNOWN) { + struct stat st {}; + auto full_path = proc_dir + "/" + entry->d_name; + if (stat(full_path.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { + continue; + } + } + bool all_digits = true; + for (const char * p = entry->d_name; *p; ++p) { + if (*p < '0' || *p > '9') { + all_digits = false; + break; + } + } + if (!all_digits) { + continue; + } + + auto cmdline_path = proc_dir + "/" + entry->d_name + "/cmdline"; + std::string found_node; + std::string found_ns; + if (parse_ros_args(cmdline_path, found_node, found_ns)) { + if (found_node == node_name && (node_namespace.empty() || found_ns == node_namespace)) { + char * end = nullptr; + long pid_val = std::strtol(entry->d_name, &end, 10); + if (end == entry->d_name || *end != '\0' || pid_val <= 0) { + continue; + } + return static_cast(pid_val); + } + } + } + + return tl::make_unexpected("No process found for node " + node_namespace + "/" + node_name); +} + +tl::expected read_system_uptime(const std::string & root) { + auto path = root + "/proc/uptime"; + std::ifstream f(path); + if (!f.is_open()) { + return tl::make_unexpected("Cannot open " + path); + } + double uptime = 0.0; + if (!(f >> uptime)) { + return tl::make_unexpected("Cannot parse " + path); + } + return uptime; +} + +// --- PidCache implementation --- + +PidCache::PidCache(std::chrono::steady_clock::duration ttl) : ttl_(ttl) { +} + +std::optional PidCache::lookup(const std::string & node_fqn, const std::string & root) { + { + std::shared_lock lock(mutex_); + auto now = std::chrono::steady_clock::now(); + if (now - last_refresh_ < ttl_) { + auto it = node_to_pid_.find(node_fqn); + if (it != node_to_pid_.end()) { + return it->second; + } + return std::nullopt; + } + } + // TTL expired - refresh + refresh(root); + + std::shared_lock lock(mutex_); + auto it = node_to_pid_.find(node_fqn); + if (it != node_to_pid_.end()) { + return it->second; + } + return std::nullopt; +} + +void PidCache::refresh(const std::string & root) { + std::unique_lock lock(mutex_); + + // Double-check TTL under exclusive lock to avoid redundant scans + auto now = std::chrono::steady_clock::now(); + if (now - last_refresh_ < ttl_) { + return; + } + + node_to_pid_.clear(); + auto proc_dir = root + "/proc"; + UniqueDir dir(opendir(proc_dir.c_str())); + if (!dir) { + last_refresh_ = std::chrono::steady_clock::now(); + return; + } + + struct dirent * entry = nullptr; + while ((entry = readdir(dir.get())) != nullptr) { + if (entry->d_type != DT_DIR && entry->d_type != DT_UNKNOWN) { + continue; + } + if (entry->d_type == DT_UNKNOWN) { + struct stat st {}; + auto full_path = proc_dir + "/" + entry->d_name; + if (stat(full_path.c_str(), &st) != 0 || !S_ISDIR(st.st_mode)) { + continue; + } + } + bool all_digits = true; + for (const char * p = entry->d_name; *p; ++p) { + if (*p < '0' || *p > '9') { + all_digits = false; + break; + } + } + if (!all_digits) { + continue; + } + + auto cmdline_path = proc_dir + "/" + entry->d_name + "/cmdline"; + std::string node_name; + std::string node_ns; + if (parse_ros_args(cmdline_path, node_name, node_ns)) { + auto fqn = (node_ns == "/" || node_ns.empty()) ? "/" + node_name : node_ns + "/" + node_name; + char * end = nullptr; + long pid_val = std::strtol(entry->d_name, &end, 10); + if (end != entry->d_name && *end == '\0' && pid_val > 0) { + node_to_pid_[fqn] = static_cast(pid_val); + } + } + } + last_refresh_ = std::chrono::steady_clock::now(); +} + +size_t PidCache::size() const { + std::shared_lock lock(mutex_); + return node_to_pid_.size(); +} + +} // namespace ros2_medkit_linux_introspection diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/procfs_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/procfs_plugin.cpp new file mode 100644 index 000000000..97b7d44b9 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/procfs_plugin.cpp @@ -0,0 +1,171 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp" +#include "ros2_medkit_gateway/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/providers/introspection_provider.hpp" +#include "ros2_medkit_linux_introspection/plugin_config.hpp" +#include "ros2_medkit_linux_introspection/proc_reader.hpp" +#include "ros2_medkit_linux_introspection/procfs_utils.hpp" + +#include +#include + +#include +#include +#include + +using namespace ros2_medkit_gateway; // NOLINT(build/namespaces) + +class ProcfsPlugin : public GatewayPlugin, public IntrospectionProvider { + public: + std::string name() const override { + return "procfs_introspection"; + } + + void configure(const nlohmann::json & config) override { + auto cfg = ros2_medkit_linux_introspection::parse_introspection_config(config); + pid_cache_ = std::move(cfg.pid_cache); + proc_root_ = std::move(cfg.proc_root); + } + + void set_context(PluginContext & ctx) override { + ctx_ = &ctx; + ctx.register_capability(SovdEntityType::APP, "x-medkit-procfs"); + ctx.register_capability(SovdEntityType::COMPONENT, "x-medkit-procfs"); + } + + void register_routes(httplib::Server & server, const std::string & api_prefix) override { + // App-level endpoint + server.Get((api_prefix + R"(/apps/([^/]+)/x-medkit-procfs)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_app_request(req, res); + }); + + // Component-level aggregation endpoint + server.Get((api_prefix + R"(/components/([^/]+)/x-medkit-procfs)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_component_request(req, res); + }); + } + + IntrospectionResult introspect(const IntrospectionInput & input) override { + IntrospectionResult result; + pid_cache_->refresh(proc_root_); + + auto sys_uptime = ros2_medkit_linux_introspection::read_system_uptime(proc_root_); + double uptime_val = sys_uptime ? *sys_uptime : 0.0; + + for (const auto & app : input.apps) { + auto fqn = app.effective_fqn(); + if (fqn.empty()) { + continue; + } + + auto pid_opt = pid_cache_->lookup(fqn, proc_root_); + if (!pid_opt) { + continue; + } + + auto proc_info = ros2_medkit_linux_introspection::read_process_info(*pid_opt, proc_root_); + if (!proc_info) { + continue; + } + + result.metadata[app.id] = ros2_medkit_linux_introspection::process_info_to_json(*proc_info, uptime_val); + } + return result; + } + + private: + PluginContext * ctx_{nullptr}; + std::unique_ptr pid_cache_ = + std::make_unique(); + std::string proc_root_{"/"}; + + void handle_app_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto pid_opt = pid_cache_->lookup(entity->fqn, proc_root_); + if (!pid_opt) { + PluginContext::send_error(res, 404, "x-medkit-pid-lookup-failed", "Process not found for entity " + entity_id); + return; + } + + auto proc_info = ros2_medkit_linux_introspection::read_process_info(*pid_opt, proc_root_); + if (!proc_info) { + PluginContext::send_error(res, 503, "x-medkit-proc-read-failed", + "Failed to read process information for entity " + entity_id); + return; + } + + auto sys_uptime = ros2_medkit_linux_introspection::read_system_uptime(proc_root_); + PluginContext::send_json( + res, ros2_medkit_linux_introspection::process_info_to_json(*proc_info, sys_uptime ? *sys_uptime : 0.0)); + } + + void handle_component_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto child_apps = ctx_->get_child_apps(entity_id); + std::map processes; // Deduplicate by PID + + auto sys_uptime = ros2_medkit_linux_introspection::read_system_uptime(proc_root_); + double uptime_val = sys_uptime ? *sys_uptime : 0.0; + + for (const auto & app : child_apps) { + auto pid_opt = pid_cache_->lookup(app.fqn, proc_root_); + if (!pid_opt) { + continue; + } + + if (processes.find(*pid_opt) == processes.end()) { + auto proc_info = ros2_medkit_linux_introspection::read_process_info(*pid_opt, proc_root_); + if (!proc_info) { + continue; + } + auto j = ros2_medkit_linux_introspection::process_info_to_json(*proc_info, uptime_val); + j["node_ids"] = nlohmann::json::array(); + processes[*pid_opt] = std::move(j); + } + processes[*pid_opt]["node_ids"].push_back(app.id); + } + + nlohmann::json result; + result["processes"] = nlohmann::json::array(); + for (auto & [_, proc_json] : processes) { + result["processes"].push_back(std::move(proc_json)); + } + PluginContext::send_json(res, result); + } +}; + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return PLUGIN_API_VERSION; +} +extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { + return new ProcfsPlugin(); +} +extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * p) { + return static_cast(p); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/systemd_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/systemd_plugin.cpp new file mode 100644 index 000000000..1849d7afe --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/src/systemd_plugin.cpp @@ -0,0 +1,290 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +#include "ros2_medkit_gateway/plugins/gateway_plugin.hpp" +#include "ros2_medkit_gateway/plugins/plugin_context.hpp" +#include "ros2_medkit_gateway/plugins/plugin_types.hpp" +#include "ros2_medkit_gateway/providers/introspection_provider.hpp" +#include "ros2_medkit_linux_introspection/plugin_config.hpp" +#include "ros2_medkit_linux_introspection/proc_reader.hpp" +#include "ros2_medkit_linux_introspection/systemd_utils.hpp" + +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +using namespace ros2_medkit_gateway; // NOLINT(build/namespaces) + +namespace { + +// RAII wrapper for sd_bus +struct SdBusDeleter { + void operator()(sd_bus * bus) const { + sd_bus_unref(bus); + } +}; +using SdBusPtr = std::unique_ptr; + +struct UnitInfo { + std::string unit; + std::string unit_type; + std::string active_state; + std::string sub_state; + uint32_t restart_count{0}; + uint64_t watchdog_usec{0}; +}; + +// Query unit properties via sd-bus +std::optional query_unit_info(const std::string & unit_name, sd_bus * shared_bus = nullptr) { + SdBusPtr owned_bus; + sd_bus * bus = shared_bus; + if (!bus) { + sd_bus * raw_bus = nullptr; + if (sd_bus_open_system(&raw_bus) < 0) { + return std::nullopt; + } + owned_bus.reset(raw_bus); + bus = raw_bus; + } + + UnitInfo info; + info.unit = unit_name; + + auto dot_pos = unit_name.rfind('.'); + if (dot_pos != std::string::npos) { + info.unit_type = unit_name.substr(dot_pos + 1); + } + + auto obj_path = "/org/freedesktop/systemd1/unit/" + ros2_medkit_linux_introspection::escape_unit_for_dbus(unit_name); + const char * iface = "org.freedesktop.systemd1.Unit"; + + // ActiveState + char * value = nullptr; + if (sd_bus_get_property_string(bus, "org.freedesktop.systemd1", obj_path.c_str(), iface, "ActiveState", nullptr, + &value) >= 0 && + value) { + info.active_state = value; + free(value); // NOLINT(cppcoreguidelines-no-malloc) + } + + // SubState + value = nullptr; + if (sd_bus_get_property_string(bus, "org.freedesktop.systemd1", obj_path.c_str(), iface, "SubState", nullptr, + &value) >= 0 && + value) { + info.sub_state = value; + free(value); // NOLINT(cppcoreguidelines-no-malloc) + } + + // NRestarts (Service-specific property) + const char * svc_iface = "org.freedesktop.systemd1.Service"; + sd_bus_error error = SD_BUS_ERROR_NULL; + uint32_t nrestarts = 0; + if (sd_bus_get_property_trivial(bus, "org.freedesktop.systemd1", obj_path.c_str(), svc_iface, "NRestarts", &error, + 'u', &nrestarts) >= 0) { + info.restart_count = nrestarts; + } + sd_bus_error_free(&error); + + // WatchdogUSec (Service-specific property) + uint64_t watchdog = 0; + error = SD_BUS_ERROR_NULL; + if (sd_bus_get_property_trivial(bus, "org.freedesktop.systemd1", obj_path.c_str(), svc_iface, "WatchdogUSec", &error, + 't', &watchdog) >= 0) { + info.watchdog_usec = watchdog; + } + sd_bus_error_free(&error); + + return info; +} + +} // namespace + +class SystemdPlugin : public GatewayPlugin, public IntrospectionProvider { + public: + std::string name() const override { + return "systemd_introspection"; + } + + void configure(const nlohmann::json & config) override { + auto cfg = ros2_medkit_linux_introspection::parse_introspection_config(config); + pid_cache_ = std::move(cfg.pid_cache); + proc_root_ = std::move(cfg.proc_root); + } + + void set_context(PluginContext & ctx) override { + ctx_ = &ctx; + ctx.register_capability(SovdEntityType::APP, "x-medkit-systemd"); + ctx.register_capability(SovdEntityType::COMPONENT, "x-medkit-systemd"); + } + + void register_routes(httplib::Server & server, const std::string & api_prefix) override { + server.Get((api_prefix + R"(/apps/([^/]+)/x-medkit-systemd)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_app_request(req, res); + }); + server.Get((api_prefix + R"(/components/([^/]+)/x-medkit-systemd)").c_str(), + [this](const httplib::Request & req, httplib::Response & res) { + handle_component_request(req, res); + }); + } + + IntrospectionResult introspect(const IntrospectionInput & input) override { + IntrospectionResult result; + pid_cache_->refresh(proc_root_); + + sd_bus * raw_bus = nullptr; + SdBusPtr request_bus; + if (sd_bus_open_system(&raw_bus) >= 0) { + request_bus.reset(raw_bus); + } + + for (const auto & app : input.apps) { + auto fqn = app.effective_fqn(); + if (fqn.empty()) { + continue; + } + + auto pid_opt = pid_cache_->lookup(fqn, proc_root_); + if (!pid_opt) { + continue; + } + + char * unit_cstr = nullptr; + if (sd_pid_get_unit(*pid_opt, &unit_cstr) < 0 || !unit_cstr) { + continue; + } + std::string unit_name(unit_cstr); + free(unit_cstr); // NOLINT(cppcoreguidelines-no-malloc) + + auto unit_info = query_unit_info(unit_name, request_bus.get()); + if (!unit_info) { + continue; + } + + result.metadata[app.id] = unit_info_to_json(*unit_info); + } + return result; + } + + private: + PluginContext * ctx_{nullptr}; + std::unique_ptr pid_cache_ = + std::make_unique(); + std::string proc_root_{"/"}; + + static nlohmann::json unit_info_to_json(const UnitInfo & info) { + return { + {"unit", info.unit}, {"unit_type", info.unit_type}, {"active_state", info.active_state}, + {"sub_state", info.sub_state}, {"restart_count", info.restart_count}, {"watchdog_usec", info.watchdog_usec}}; + } + + void handle_app_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto pid_opt = pid_cache_->lookup(entity->fqn, proc_root_); + if (!pid_opt) { + PluginContext::send_error(res, 404, "x-medkit-pid-lookup-failed", "Process not found for entity " + entity_id); + return; + } + + char * unit_cstr = nullptr; + if (sd_pid_get_unit(*pid_opt, &unit_cstr) < 0 || !unit_cstr) { + PluginContext::send_error(res, 404, "x-medkit-not-in-systemd-unit", + "Entity " + entity_id + " is not managed by a systemd unit"); + return; + } + std::string unit_name(unit_cstr); + free(unit_cstr); // NOLINT(cppcoreguidelines-no-malloc) + + auto unit_info = query_unit_info(unit_name); + if (!unit_info) { + PluginContext::send_error(res, 503, "x-medkit-systemd-query-failed", + "Failed to query systemd properties for entity " + entity_id); + return; + } + + PluginContext::send_json(res, unit_info_to_json(*unit_info)); + } + + void handle_component_request(const httplib::Request & req, httplib::Response & res) { + auto entity_id = req.matches[1].str(); + auto entity = ctx_->validate_entity_for_route(req, res, entity_id); + if (!entity) { + return; + } + + auto child_apps = ctx_->get_child_apps(entity_id); + std::map units; // Deduplicate by unit name + + sd_bus * raw_bus = nullptr; + SdBusPtr request_bus; + if (sd_bus_open_system(&raw_bus) >= 0) { + request_bus.reset(raw_bus); + } + + for (const auto & app : child_apps) { + auto pid_opt = pid_cache_->lookup(app.fqn, proc_root_); + if (!pid_opt) { + continue; + } + + char * unit_cstr = nullptr; + if (sd_pid_get_unit(*pid_opt, &unit_cstr) < 0 || !unit_cstr) { + continue; + } + std::string unit_name(unit_cstr); + free(unit_cstr); // NOLINT(cppcoreguidelines-no-malloc) + + if (units.find(unit_name) == units.end()) { + auto unit_info = query_unit_info(unit_name, request_bus.get()); + if (!unit_info) { + continue; + } + auto j = unit_info_to_json(*unit_info); + j["node_ids"] = nlohmann::json::array(); + units[unit_name] = std::move(j); + } + units[unit_name]["node_ids"].push_back(app.id); + } + + nlohmann::json result; + result["units"] = nlohmann::json::array(); + for (auto & [_, unit_json] : units) { + result["units"].push_back(std::move(unit_json)); + } + PluginContext::send_json(res, result); + } +}; + +extern "C" GATEWAY_PLUGIN_EXPORT int plugin_api_version() { + return PLUGIN_API_VERSION; +} +extern "C" GATEWAY_PLUGIN_EXPORT GatewayPlugin * create_plugin() { + return new SystemdPlugin(); +} +extern "C" GATEWAY_PLUGIN_EXPORT IntrospectionProvider * get_introspection_provider(GatewayPlugin * p) { + return static_cast(p); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_cgroup_reader.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_cgroup_reader.cpp new file mode 100644 index 000000000..425eee747 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_cgroup_reader.cpp @@ -0,0 +1,237 @@ +// 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_linux_introspection/cgroup_reader.hpp" + +namespace fs = std::filesystem; +using namespace ros2_medkit_linux_introspection; + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ExtractDockerContainerId) { + std::string path = + "/system.slice/" + "docker-a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.scope"; + auto id = extract_container_id(path); + EXPECT_EQ(id, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ExtractPodmanContainerId) { + std::string path = + "/user.slice/user-1000.slice/user@1000.service/" + "libpod-aabbccddee112233aabbccddee112233aabbccddee112233aabbccddee112233.scope"; + auto id = extract_container_id(path); + EXPECT_EQ(id, "aabbccddee112233aabbccddee112233aabbccddee112233aabbccddee112233"); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ExtractContainerdContainerId) { + std::string path = + "/system.slice/containerd.service/" + "cri-containerd-deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678.scope"; + auto id = extract_container_id(path); + EXPECT_EQ(id, "deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ExtractNoContainerId) { + EXPECT_TRUE(extract_container_id("/user.slice/user-1000.slice/session-1.scope").empty()); + EXPECT_TRUE(extract_container_id("/").empty()); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, DetectRuntime) { + EXPECT_EQ(detect_runtime("/system.slice/docker-abc123.scope"), "docker"); + EXPECT_EQ(detect_runtime("/user.slice/libpod-abc123.scope"), "podman"); + EXPECT_EQ(detect_runtime("/system.slice/cri-containerd-abc123.scope"), "containerd"); + EXPECT_TRUE(detect_runtime("/user.slice/session-1.scope").empty()); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, IsContainerizedSyntheticProc) { + auto tmpdir = fs::temp_directory_path() / "test_cgroup"; + fs::create_directories(tmpdir / "proc" / "42"); + + // Process inside Docker container + { + std::ofstream f(tmpdir / "proc" / "42" / "cgroup"); + f << "0::/system.slice/" + "docker-a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2.scope\n"; + } + + EXPECT_TRUE(is_containerized(42, tmpdir.string())); + + // Process on host + fs::create_directories(tmpdir / "proc" / "43"); + { + std::ofstream f(tmpdir / "proc" / "43" / "cgroup"); + f << "0::/user.slice/user-1000.slice/session-1.scope\n"; + } + + EXPECT_FALSE(is_containerized(43, tmpdir.string())); + + fs::remove_all(tmpdir); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ReadCgroupInfoWithResourceLimits) { + auto tmpdir = fs::temp_directory_path() / "test_cgroup_limits"; + fs::create_directories(tmpdir / "proc" / "42"); + fs::create_directories(tmpdir / "sys" / "fs" / "cgroup" / "system.slice" / + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope"); + + // cgroup file + { + std::ofstream f(tmpdir / "proc" / "42" / "cgroup"); + f << "0::/system.slice/" + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope\n"; + } + + auto cgroup_dir = tmpdir / "sys" / "fs" / "cgroup" / "system.slice" / + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope"; + + // memory.max + { + std::ofstream f(cgroup_dir / "memory.max"); + f << "1073741824\n"; + } + + // cpu.max (quota period) + { + std::ofstream f(cgroup_dir / "cpu.max"); + f << "100000 100000\n"; + } + + auto result = read_cgroup_info(42, tmpdir.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto & info = result.value(); + EXPECT_EQ(info.container_id, "aabb112233445566aabb112233445566aabb112233445566aabb112233445566"); + EXPECT_EQ(info.container_runtime, "docker"); + ASSERT_TRUE(info.memory_limit_bytes.has_value()); + EXPECT_EQ(info.memory_limit_bytes.value(), 1073741824u); + ASSERT_TRUE(info.cpu_quota_us.has_value()); + EXPECT_EQ(info.cpu_quota_us.value(), 100000); + ASSERT_TRUE(info.cpu_period_us.has_value()); + EXPECT_EQ(info.cpu_period_us.value(), 100000); + + fs::remove_all(tmpdir); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ReadCgroupInfoUnlimitedResources) { + auto tmpdir = fs::temp_directory_path() / "test_cgroup_unlimited"; + fs::create_directories(tmpdir / "proc" / "42"); + fs::create_directories(tmpdir / "sys" / "fs" / "cgroup" / "system.slice" / + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope"); + + // cgroup file + { + std::ofstream f(tmpdir / "proc" / "42" / "cgroup"); + f << "0::/system.slice/" + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope\n"; + } + + auto cgroup_dir = tmpdir / "sys" / "fs" / "cgroup" / "system.slice" / + "docker-aabb112233445566aabb112233445566aabb112233445566aabb112233445566.scope"; + + // memory.max = "max" means unlimited + { + std::ofstream f(cgroup_dir / "memory.max"); + f << "max\n"; + } + + // cpu.max = "max 100000" means unlimited quota + { + std::ofstream f(cgroup_dir / "cpu.max"); + f << "max 100000\n"; + } + + auto result = read_cgroup_info(42, tmpdir.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto & info = result.value(); + // memory_limit_bytes should not be set when "max" + EXPECT_FALSE(info.memory_limit_bytes.has_value()); + // cpu_quota_us should not be set when "max", but period should be + EXPECT_FALSE(info.cpu_quota_us.has_value()); + ASSERT_TRUE(info.cpu_period_us.has_value()); + EXPECT_EQ(info.cpu_period_us.value(), 100000); + + fs::remove_all(tmpdir); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ReadCgroupInfoMissingResourceFiles) { + auto tmpdir = fs::temp_directory_path() / "test_cgroup_nores"; + fs::create_directories(tmpdir / "proc" / "42"); + + // cgroup file points to path with no resource files + { + std::ofstream f(tmpdir / "proc" / "42" / "cgroup"); + f << "0::/user.slice/user-1000.slice/session-1.scope\n"; + } + + auto result = read_cgroup_info(42, tmpdir.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto & info = result.value(); + EXPECT_TRUE(info.container_id.empty()); + EXPECT_TRUE(info.container_runtime.empty()); + EXPECT_FALSE(info.memory_limit_bytes.has_value()); + EXPECT_FALSE(info.cpu_quota_us.has_value()); + EXPECT_FALSE(info.cpu_period_us.has_value()); + + fs::remove_all(tmpdir); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ReadCgroupInfoMissingCgroupFile) { + auto tmpdir = fs::temp_directory_path() / "test_cgroup_nocg"; + fs::create_directories(tmpdir / "proc" / "42"); + // No cgroup file at all + + auto result = read_cgroup_info(42, tmpdir.string()); + ASSERT_FALSE(result.has_value()); + EXPECT_FALSE(result.error().empty()); + + fs::remove_all(tmpdir); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, ExtractDockerOldStyleContainerId) { + std::string path = "/docker/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + auto id = extract_container_id(path); + EXPECT_EQ(id, "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"); +} + +// @verifies REQ_INTEROP_003 +TEST(CgroupReader, CgroupV1FormatNotSupported) { + // cgroup v1 uses "hierarchy-ID:controller-list:cgroup-path" format. + // Our reader only supports cgroup v2 ("0::"). + // This test documents the intentional limitation. + auto tmpdir = fs::temp_directory_path() / "test_cgroup_v1"; + fs::create_directories(tmpdir / "proc" / "42"); + { + std::ofstream f(tmpdir / "proc" / "42" / "cgroup"); + f << "12:memory:/docker/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n" + << "11:cpu:/docker/a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n"; + } + // cgroup v1 format is not supported - returns false + EXPECT_FALSE(is_containerized(42, tmpdir.string())); + fs::remove_all(tmpdir); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_container_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_container_plugin.cpp new file mode 100644 index 000000000..08f9d610c --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_container_plugin.cpp @@ -0,0 +1,55 @@ +// 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 "ros2_medkit_linux_introspection/container_utils.hpp" + +using namespace ros2_medkit_linux_introspection; + +// @verifies REQ_INTEROP_003 +TEST(ContainerPlugin, CgroupInfoToJsonAllFields) { + CgroupInfo info; + info.container_id = "a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2"; + info.container_runtime = "docker"; + info.memory_limit_bytes = 1073741824; + info.cpu_quota_us = 100000; + info.cpu_period_us = 100000; + + auto j = cgroup_info_to_json(info); + EXPECT_EQ(j["container_id"], info.container_id); + EXPECT_EQ(j["runtime"], "docker"); + EXPECT_EQ(j["memory_limit_bytes"], 1073741824u); + EXPECT_EQ(j["cpu_quota_us"], 100000); + EXPECT_EQ(j["cpu_period_us"], 100000); +} + +// @verifies REQ_INTEROP_003 +TEST(ContainerPlugin, CgroupInfoToJsonMissingOptionals) { + CgroupInfo info; + info.container_id = "deadbeef12345678deadbeef12345678deadbeef12345678deadbeef12345678"; + info.container_runtime = "containerd"; + // Leave optionals unset + + auto j = cgroup_info_to_json(info); + EXPECT_EQ(j["container_id"], info.container_id); + EXPECT_EQ(j["runtime"], "containerd"); + EXPECT_FALSE(j.contains("memory_limit_bytes")); + EXPECT_FALSE(j.contains("cpu_quota_us")); + EXPECT_FALSE(j.contains("cpu_period_us")); +} + +// @verifies REQ_INTEROP_003 +TEST(ContainerPlugin, NotContainerizedSkipped) { + EXPECT_FALSE(is_containerized(1, "/nonexistent_root")); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_pid_cache.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_pid_cache.cpp new file mode 100644 index 000000000..771465db7 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_pid_cache.cpp @@ -0,0 +1,191 @@ +// 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 "ros2_medkit_linux_introspection/proc_reader.hpp" + +#include +#include +#include +#include + +namespace fs = std::filesystem; +using namespace ros2_medkit_linux_introspection; + +class PidCacheTest : public ::testing::Test { + protected: + void SetUp() override { + tmpdir_ = fs::temp_directory_path() / "test_pid_cache"; + fs::create_directories(tmpdir_ / "proc" / "100"); + fs::create_directories(tmpdir_ / "proc" / "200"); + + // PID 100: /demo/talker + { + std::ofstream f(tmpdir_ / "proc" / "100" / "cmdline"); + std::string cmdline = std::string("/usr/bin/talker") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=talker") + '\0' + std::string("__ns:=/demo") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + // PID 200: /demo/listener + { + std::ofstream f(tmpdir_ / "proc" / "200" / "cmdline"); + std::string cmdline = std::string("/usr/bin/listener") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=listener") + '\0' + std::string("__ns:=/demo") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + } + + void TearDown() override { + fs::remove_all(tmpdir_); + } + + fs::path tmpdir_; +}; + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, LookupAfterRefresh) { + PidCache cache(std::chrono::seconds{60}); + cache.refresh(tmpdir_.string()); + + auto pid = cache.lookup("/demo/talker", tmpdir_.string()); + ASSERT_TRUE(pid.has_value()); + EXPECT_EQ(pid.value(), 100); + + pid = cache.lookup("/demo/listener", tmpdir_.string()); + ASSERT_TRUE(pid.has_value()); + EXPECT_EQ(pid.value(), 200); + + EXPECT_EQ(cache.size(), 2u); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, LookupMissingNode) { + PidCache cache(std::chrono::seconds{60}); + cache.refresh(tmpdir_.string()); + + auto pid = cache.lookup("/demo/nonexistent", tmpdir_.string()); + EXPECT_FALSE(pid.has_value()); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, AutoRefreshOnTTLExpiry) { + PidCache cache(std::chrono::milliseconds{1}); // 1ms TTL + cache.refresh(tmpdir_.string()); + + // Wait for TTL to expire + std::this_thread::sleep_for(std::chrono::milliseconds{5}); + + // Add a new node + fs::create_directories(tmpdir_ / "proc" / "300"); + { + std::ofstream f(tmpdir_ / "proc" / "300" / "cmdline"); + std::string cmdline = std::string("/usr/bin/chatter") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=chatter") + '\0' + std::string("__ns:=/demo") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + // lookup should trigger refresh and find the new node + auto pid = cache.lookup("/demo/chatter", tmpdir_.string()); + ASSERT_TRUE(pid.has_value()); + EXPECT_EQ(pid.value(), 300); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, NoRefreshWithinTTL) { + PidCache cache(std::chrono::seconds{60}); + cache.refresh(tmpdir_.string()); + EXPECT_EQ(cache.size(), 2u); + + // Add a new node - should NOT be picked up since TTL hasn't expired + fs::create_directories(tmpdir_ / "proc" / "300"); + { + std::ofstream f(tmpdir_ / "proc" / "300" / "cmdline"); + std::string cmdline = std::string("/usr/bin/chatter") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=chatter") + '\0' + std::string("__ns:=/demo") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + auto pid = cache.lookup("/demo/chatter", tmpdir_.string()); + EXPECT_FALSE(pid.has_value()); // Not found - cache still has old data + EXPECT_EQ(cache.size(), 2u); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, EmptyProcDir) { + auto empty_dir = fs::temp_directory_path() / "test_pid_cache_empty"; + fs::create_directories(empty_dir / "proc"); + + PidCache cache(std::chrono::seconds{60}); + cache.refresh(empty_dir.string()); + EXPECT_EQ(cache.size(), 0u); + + auto pid = cache.lookup("/any/node", empty_dir.string()); + EXPECT_FALSE(pid.has_value()); + + fs::remove_all(empty_dir); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, NonexistentProcDir) { + auto bad_dir = fs::temp_directory_path() / "test_pid_cache_nonexistent"; + fs::remove_all(bad_dir); // Ensure it doesn't exist + + PidCache cache(std::chrono::seconds{60}); + cache.refresh(bad_dir.string()); + EXPECT_EQ(cache.size(), 0u); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, LookupRootNamespaceNode) { + fs::create_directories(tmpdir_ / "proc" / "500"); + { + std::ofstream f(tmpdir_ / "proc" / "500" / "cmdline"); + std::string cmdline = std::string("/usr/bin/talker") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=talker") + '\0' + std::string("__ns:=/") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + PidCache cache(std::chrono::milliseconds(100)); + auto result = cache.lookup("/talker", tmpdir_.string()); + ASSERT_TRUE(result.has_value()) << "Root-namespace node lookup failed"; + EXPECT_EQ(*result, 500); +} + +// @verifies REQ_INTEROP_003 +TEST_F(PidCacheTest, ConcurrentLookupDoesNotCrash) { + PidCache cache(std::chrono::milliseconds{1}); + + constexpr int kNumThreads = 8; + constexpr int kIterations = 100; + std::vector threads; + threads.reserve(kNumThreads); + + for (int i = 0; i < kNumThreads; ++i) { + threads.emplace_back([&cache, this]() { + for (int j = 0; j < kIterations; ++j) { + cache.lookup("/demo/talker", tmpdir_.string()); + cache.lookup("/demo/listener", tmpdir_.string()); + cache.lookup("/demo/nonexistent", tmpdir_.string()); + } + }); + } + + for (auto & t : threads) { + t.join(); + } + + // No crash or deadlock = pass + EXPECT_GE(cache.size(), 0u); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_proc_reader.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_proc_reader.cpp new file mode 100644 index 000000000..79bc2e54d --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_proc_reader.cpp @@ -0,0 +1,209 @@ +// 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_linux_introspection/proc_reader.hpp" + +namespace fs = std::filesystem; +using namespace ros2_medkit_linux_introspection; + +// --------------------------------------------------------------------------- +// Fixture for tests that create a synthetic /proc tree +// --------------------------------------------------------------------------- + +class SyntheticProcTest : public ::testing::Test { + protected: + void SetUp() override { + tmpdir_ = fs::temp_directory_path() / "test_proc_reader_synth"; + fs::remove_all(tmpdir_); + fs::create_directories(tmpdir_ / "proc"); + } + + void TearDown() override { + fs::remove_all(tmpdir_); + } + + fs::path tmpdir_; +}; + +// --------------------------------------------------------------------------- +// Standalone tests (no synthetic dirs needed) +// --------------------------------------------------------------------------- + +// @verifies REQ_INTEROP_003 +TEST(ProcReader, ReadSelfProcess) { + auto result = read_process_info(getpid()); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto & info = result.value(); + EXPECT_EQ(info.pid, getpid()); + EXPECT_GT(info.ppid, 0); + EXPECT_GT(info.rss_bytes, 0u); + EXPECT_GT(info.vm_size_bytes, 0u); + EXPECT_GT(info.num_threads, 0u); + EXPECT_FALSE(info.exe_path.empty()); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcReader, ReadNonexistentPidFails) { + auto result = read_process_info(999999999); + ASSERT_FALSE(result.has_value()); + EXPECT_FALSE(result.error().empty()); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcReader, StateFieldPopulated) { + // Read our own process - should have state "R" (running) or "S" (sleeping) + auto result = read_process_info(getpid()); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_FALSE(result.value().state.empty()); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcReader, ReadSystemUptime) { + auto result = read_system_uptime("/"); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_GT(*result, 0.0); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcReader, ReadSystemUptimeMissingFile) { + auto result = read_system_uptime("/nonexistent_root"); + ASSERT_FALSE(result.has_value()); +} + +// --------------------------------------------------------------------------- +// Fixture-based tests (use SyntheticProcTest) +// --------------------------------------------------------------------------- + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, ReadSyntheticProc) { + fs::create_directories(tmpdir_ / "proc" / "42"); + + // Write synthetic /proc/42/stat + { + std::ofstream f(tmpdir_ / "proc" / "42" / "stat"); + f << "42 (test_node) S 1 42 42 0 -1 4194304 1000 0 0 0 150 30 0 0 20 0 3 0 12345 " + "1048576 128 18446744073709551615 0 0 0 0 0 0 0 0 0 0 0 0 17 0 0 0 0 0 0 0 0 0 0 0 0 0 0"; + } + + // Write synthetic /proc/42/status + { + std::ofstream f(tmpdir_ / "proc" / "42" / "status"); + f << "Name:\ttest_node\nVmSize:\t1024 kB\nVmRSS:\t512 kB\n"; + } + + // Write synthetic /proc/42/cmdline (null-separated) + { + std::ofstream f(tmpdir_ / "proc" / "42" / "cmdline"); + std::string cmdline = std::string("/usr/bin/test_node") + '\0' + std::string("--ros-args") + '\0' + + std::string("-r") + '\0' + std::string("__node:=my_node") + '\0' + + std::string("__ns:=/test_ns") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + // Symlink for /proc/42/exe + fs::create_symlink("/usr/bin/test_node", tmpdir_ / "proc" / "42" / "exe"); + + auto result = read_process_info(42, tmpdir_.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + + auto & info = result.value(); + EXPECT_EQ(info.pid, 42); + EXPECT_EQ(info.ppid, 1); + EXPECT_EQ(info.rss_bytes, 512u * 1024u); + EXPECT_EQ(info.vm_size_bytes, 1024u * 1024u); + EXPECT_EQ(info.cpu_user_ticks, 150u); + EXPECT_EQ(info.cpu_system_ticks, 30u); + EXPECT_EQ(info.num_threads, 3u); + EXPECT_EQ(info.start_time_ticks, 12345u); +} + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, FindPidForNodeInSyntheticProc) { + fs::create_directories(tmpdir_ / "proc" / "100"); + fs::create_directories(tmpdir_ / "proc" / "200"); + + // PID 100: ROS 2 node "talker" in namespace "/demo" + { + std::ofstream f(tmpdir_ / "proc" / "100" / "cmdline"); + std::string cmdline = std::string("/usr/bin/talker") + '\0' + std::string("--ros-args") + '\0' + + std::string("__node:=talker") + '\0' + std::string("__ns:=/demo") + '\0'; + f.write(cmdline.data(), static_cast(cmdline.size())); + } + + // PID 200: non-ROS process + { + std::ofstream f(tmpdir_ / "proc" / "200" / "cmdline"); + f << "/usr/bin/bash"; + } + + auto result = find_pid_for_node("talker", "/demo", tmpdir_.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_EQ(result.value(), 100); + + // Node not found + auto missing = find_pid_for_node("listener", "/demo", tmpdir_.string()); + ASSERT_FALSE(missing.has_value()); +} + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, ReadSystemUptimeSynthetic) { + { + std::ofstream f(tmpdir_ / "proc" / "uptime"); + f << "12345.67 98765.43\n"; + } + auto result = read_system_uptime(tmpdir_.string()); + ASSERT_TRUE(result.has_value()) << result.error(); + EXPECT_NEAR(*result, 12345.67, 0.01); +} + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, MalformedStatMissingComm) { + fs::create_directories(tmpdir_ / "proc" / "42"); + { + std::ofstream f(tmpdir_ / "proc" / "42" / "stat"); + f << "42 S 1 42 42"; // Missing (comm) parens + } + auto result = read_process_info(42, tmpdir_.string()); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("Malformed"), std::string::npos); +} + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, EmptyStatFile) { + fs::create_directories(tmpdir_ / "proc" / "42"); + { + std::ofstream f(tmpdir_ / "proc" / "42" / "stat"); + // Intentionally empty + } + auto result = read_process_info(42, tmpdir_.string()); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("Cannot read"), std::string::npos); +} + +// @verifies REQ_INTEROP_003 +TEST_F(SyntheticProcTest, TruncatedStatAfterComm) { + fs::create_directories(tmpdir_ / "proc" / "42"); + { + std::ofstream f(tmpdir_ / "proc" / "42" / "stat"); + f << "42 (test_node) S 1"; // Valid comm but truncated after ppid + } + auto result = read_process_info(42, tmpdir_.string()); + ASSERT_FALSE(result.has_value()); + EXPECT_NE(result.error().find("Truncated"), std::string::npos); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_procfs_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_procfs_plugin.cpp new file mode 100644 index 000000000..3e4ad7aa1 --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_procfs_plugin.cpp @@ -0,0 +1,77 @@ +// Copyright 2026 bburda +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// 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 "ros2_medkit_linux_introspection/procfs_utils.hpp" + +using namespace ros2_medkit_linux_introspection; + +// @verifies REQ_INTEROP_003 +TEST(ProcfsPlugin, ProcessInfoToJsonAllFields) { + ProcessInfo info; + info.pid = 1234; + info.ppid = 1; + info.state = "S"; + info.exe_path = "/usr/bin/talker"; + info.cmdline = "/usr/bin/talker --ros-args __node:=talker"; + info.rss_bytes = 524288; + info.vm_size_bytes = 2097152; + info.num_threads = 4; + info.cpu_user_ticks = 1520; + info.cpu_system_ticks = 340; + info.start_time_ticks = 12345; + + double system_uptime = 50000.0; + auto j = process_info_to_json(info, system_uptime); + + EXPECT_EQ(j["pid"], 1234); + EXPECT_EQ(j["ppid"], 1); + EXPECT_EQ(j["state"], "S"); + EXPECT_EQ(j["exe"], "/usr/bin/talker"); + EXPECT_EQ(j["cmdline"], "/usr/bin/talker --ros-args __node:=talker"); + EXPECT_EQ(j["rss_bytes"], 524288u); + EXPECT_EQ(j["vm_size_bytes"], 2097152u); + EXPECT_EQ(j["threads"], 4u); + EXPECT_EQ(j["cpu_user_ticks"], 1520u); + EXPECT_EQ(j["cpu_system_ticks"], 340u); + + // cpu_*_seconds should be ticks / sysconf(_SC_CLK_TCK) + EXPECT_GT(j["cpu_user_seconds"].get(), 0.0); + EXPECT_GT(j["cpu_system_seconds"].get(), 0.0); + + // uptime_seconds should be > 0 given valid start_time and system_uptime + EXPECT_GT(j["uptime_seconds"].get(), 0.0); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcfsPlugin, ProcessInfoToJsonZeroUptime) { + ProcessInfo info; + info.pid = 1; + info.start_time_ticks = 0; // No start time + + auto j = process_info_to_json(info, 50000.0); + EXPECT_EQ(j["uptime_seconds"].get(), 0.0); +} + +// @verifies REQ_INTEROP_003 +TEST(ProcfsPlugin, ProcessInfoToJsonNegativeUptimeClamped) { + ProcessInfo info; + info.pid = 1; + // start_time_ticks larger than system_uptime * ticks_per_sec + info.start_time_ticks = 999999999; + + auto j = process_info_to_json(info, 1.0); + // Should be clamped to 0, not negative + EXPECT_GE(j["uptime_seconds"].get(), 0.0); +} diff --git a/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_systemd_plugin.cpp b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_systemd_plugin.cpp new file mode 100644 index 000000000..7aae29bdd --- /dev/null +++ b/src/ros2_medkit_discovery_plugins/ros2_medkit_linux_introspection/test/test_systemd_plugin.cpp @@ -0,0 +1,57 @@ +// 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_linux_introspection/systemd_utils.hpp" + +using namespace ros2_medkit_linux_introspection; + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeSimpleServiceName) { + EXPECT_EQ(escape_unit_for_dbus("my_service"), "my_service"); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeDotInServiceExtension) { + EXPECT_EQ(escape_unit_for_dbus("talker.service"), "talker_2eservice"); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeHyphenInUnitName) { + EXPECT_EQ(escape_unit_for_dbus("ros2-talker.service"), "ros2_2dtalker_2eservice"); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeAtSignInTemplateUnit) { + EXPECT_EQ(escape_unit_for_dbus("container@instance.service"), "container_40instance_2eservice"); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeSlashInPath) { + EXPECT_EQ(escape_unit_for_dbus("sys/devices"), "sys_2fdevices"); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, EscapeEmptyString) { + EXPECT_EQ(escape_unit_for_dbus(""), ""); +} + +// @verifies REQ_INTEROP_003 +TEST(SystemdUtils, ComponentEndpointEmptyUnitsArray) { + nlohmann::json empty_result; + empty_result["units"] = nlohmann::json::array(); + EXPECT_TRUE(empty_result["units"].is_array()); + EXPECT_TRUE(empty_result["units"].empty()); +} diff --git a/src/ros2_medkit_gateway/CHANGELOG.rst b/src/ros2_medkit_gateway/CHANGELOG.rst index 4929c4a3f..6127bf166 100644 --- a/src/ros2_medkit_gateway/CHANGELOG.rst +++ b/src/ros2_medkit_gateway/CHANGELOG.rst @@ -22,6 +22,7 @@ Changelog for package ros2_medkit_gateway * Area and function log endpoints with namespace aggregation and host-based aggregation (`#258 `_) * Entity capabilities fix: areas and functions now report correct resource collections (`#258 `_) * SOVD compliance documentation with resource collection support matrix (`#258 `_) +* Linux introspection plugins: procfs, systemd, and container plugins for process-level diagnostics via ``x-medkit-*`` vendor extension endpoints (`#263 `_) * Gateway plugin framework with dynamic C++ plugin loading (`#237 `_) * Software updates plugin with 8 SOVD-compliant endpoints (`#237 `_, `#231 `_) * SSE-based periodic data subscriptions for real-time streaming without polling (`#223 `_) diff --git a/src/ros2_medkit_gateway/CMakeLists.txt b/src/ros2_medkit_gateway/CMakeLists.txt index df5faae2a..674d8d371 100644 --- a/src/ros2_medkit_gateway/CMakeLists.txt +++ b/src/ros2_medkit_gateway/CMakeLists.txt @@ -57,7 +57,8 @@ add_compile_definitions(CPPHTTPLIB_OPENSSL_SUPPORT) # tl::expected v1.3.1 (CC0) - https://github.com/TartanLlama/expected add_library(tl_expected_iface INTERFACE) target_include_directories(tl_expected_iface SYSTEM INTERFACE - ${CMAKE_CURRENT_SOURCE_DIR}/src/vendored/tl_expected/include + $ + $ ) add_library(tl::expected ALIAS tl_expected_iface) @@ -199,6 +200,8 @@ set_target_properties(gateway_lib PROPERTIES POSITION_INDEPENDENT_CODE ON) # Gateway node executable add_executable(gateway_node src/main.cpp) target_link_libraries(gateway_node gateway_lib) +# Export symbols to dlopen'd plugins (PluginContext::send_json, send_error, etc.) +set_target_properties(gateway_node PROPERTIES ENABLE_EXPORTS ON) # Apply coverage flags to main targets if(ENABLE_COVERAGE) @@ -535,4 +538,9 @@ if(BUILD_TESTING) endif() +# Export include directories for downstream packages (plugins) +install(DIRECTORY include/ DESTINATION include) +install(DIRECTORY src/vendored/tl_expected/include/ DESTINATION include/ros2_medkit_gateway/vendored) +ament_export_include_directories(include include/ros2_medkit_gateway/vendored) + ament_package() diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_context.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_context.hpp index f0db1a317..4f01425b9 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_context.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_context.hpp @@ -67,6 +67,9 @@ class PluginContext { /// Look up an entity by ID. Returns nullopt if not found. virtual std::optional get_entity(const std::string & id) const = 0; + /// Get all Apps belonging to a Component (for aggregation endpoints) + virtual std::vector get_child_apps(const std::string & component_id) const = 0; + // ---- Fault access ---- /// List faults for a given entity. Returns JSON array of fault objects. diff --git a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_types.hpp b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_types.hpp index fdf0f99bd..f37ebedef 100644 --- a/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_types.hpp +++ b/src/ros2_medkit_gateway/include/ros2_medkit_gateway/plugins/plugin_types.hpp @@ -28,7 +28,7 @@ namespace ros2_medkit_gateway { /// Current plugin API version. Plugins must export this value from plugin_api_version(). -constexpr int PLUGIN_API_VERSION = 1; +constexpr int PLUGIN_API_VERSION = 2; /// Log severity levels for plugin logging callback enum class PluginLogLevel { kInfo, kWarn, kError }; diff --git a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp index 0e904df0a..8341fc7f1 100644 --- a/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp +++ b/src/ros2_medkit_gateway/src/plugins/plugin_context.cpp @@ -65,6 +65,20 @@ class GatewayPluginContext : public PluginContext { return std::nullopt; } + std::vector get_child_apps(const std::string & component_id) const override { + std::vector result; + const auto & cache = node_->get_thread_safe_cache(); + auto app_ids = cache.get_apps_for_component(component_id); + for (const auto & app_id : app_ids) { + if (auto app = cache.get_app(app_id)) { + result.push_back(PluginEntityInfo{SovdEntityType::APP, app_id, + "", // namespace_path not needed for Apps + app->effective_fqn()}); + } + } + return result; + } + nlohmann::json list_entity_faults(const std::string & entity_id) const override { if (!fault_manager_ || !fault_manager_->is_available()) { return nlohmann::json::array(); diff --git a/src/ros2_medkit_gateway/test/test_graph_provider_plugin.cpp b/src/ros2_medkit_gateway/test/test_graph_provider_plugin.cpp index aabad2d03..8a4aad97d 100644 --- a/src/ros2_medkit_gateway/test/test_graph_provider_plugin.cpp +++ b/src/ros2_medkit_gateway/test/test_graph_provider_plugin.cpp @@ -188,6 +188,10 @@ class FakePluginContext : public PluginContext { return it->second; } + std::vector get_child_apps(const std::string & /*component_id*/) const override { + return {}; + } + private: rclcpp::Node * node_{nullptr}; std::unordered_map entities_; diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.container b/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.container new file mode 100644 index 000000000..1f4ecda15 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.container @@ -0,0 +1,131 @@ +# 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. + +# Multi-stage build for container introspection integration tests. +# Stage 1: Build the colcon workspace (gateway + plugins + demo nodes). +# Stage 2: Runtime image - standard ROS 2 launch (no systemd needed). +# The container plugin detects containerization by reading cgroup paths. + +# ============================================================================ +# Stage 1: Builder +# ============================================================================ +FROM ros:jazzy-ros-base AS builder + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DISTRO=jazzy +ENV COLCON_WS=/root/ws + +# Build dependencies +RUN apt-get update && apt-get install -y \ + ros-jazzy-ament-lint-auto \ + ros-jazzy-ament-lint-common \ + ros-jazzy-ament-cmake-gtest \ + ros-jazzy-yaml-cpp-vendor \ + ros-jazzy-example-interfaces \ + python3-colcon-common-extensions \ + nlohmann-json3-dev \ + libcpp-httplib-dev \ + sqlite3 \ + libsqlite3-dev \ + libsystemd-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copy source tree +WORKDIR ${COLCON_WS} +COPY cmake/ ${COLCON_WS}/cmake/ +COPY src/ros2_medkit_gateway/ ${COLCON_WS}/src/ros2_medkit_gateway/ +COPY src/ros2_medkit_serialization/ ${COLCON_WS}/src/ros2_medkit_serialization/ +COPY src/ros2_medkit_msgs/ ${COLCON_WS}/src/ros2_medkit_msgs/ +COPY src/ros2_medkit_fault_manager/ ${COLCON_WS}/src/ros2_medkit_fault_manager/ +COPY src/ros2_medkit_fault_reporter/ ${COLCON_WS}/src/ros2_medkit_fault_reporter/ +COPY src/ros2_medkit_diagnostic_bridge/ ${COLCON_WS}/src/ros2_medkit_diagnostic_bridge/ +COPY src/ros2_medkit_integration_tests/ ${COLCON_WS}/src/ros2_medkit_integration_tests/ +COPY src/ros2_medkit_discovery_plugins/ ${COLCON_WS}/src/ros2_medkit_discovery_plugins/ + +# Build everything (skip test targets to speed up the image build) +RUN bash -c "source /opt/ros/jazzy/setup.bash && \ + rosdep update && \ + rosdep install --from-paths src --ignore-src -r -y \ + --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy test_msgs sqlite3' && \ + colcon build --symlink-install --cmake-args -DBUILD_TESTING=OFF" + +# ============================================================================ +# Stage 2: Runtime +# ============================================================================ +FROM ros:jazzy-ros-base + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DISTRO=jazzy +ENV COLCON_WS=/root/ws + +# Runtime dependencies only +RUN apt-get update && apt-get install -y \ + ros-jazzy-yaml-cpp-vendor \ + ros-jazzy-example-interfaces \ + nlohmann-json3-dev \ + libcpp-httplib-dev \ + sqlite3 \ + libsqlite3-dev \ + && rm -rf /var/lib/apt/lists/* + +# Copy built workspace from builder +COPY --from=builder ${COLCON_WS}/install/ ${COLCON_WS}/install/ + +# Create gateway config with container plugin enabled +RUN mkdir -p /etc/ros2_medkit && \ + cat > /etc/ros2_medkit/gateway_docker_params.yaml <<'YAML' +ros2_medkit_gateway: + ros__parameters: + server: + host: "0.0.0.0" + port: 8080 + refresh_interval_ms: 2000 + cors: + allowed_origins: ["*"] + plugins: ["container_introspection"] + plugins.container_introspection.path: "__PLUGIN_PATH__" + plugins.container_introspection.pid_cache_ttl_seconds: 5 +YAML + +# Create entrypoint that starts gateway + demo nodes +RUN cat > /entrypoint.sh <<'BASH' +#!/bin/bash +set -e + +source /opt/ros/jazzy/setup.bash +source /root/ws/install/setup.bash +export RMW_IMPLEMENTATION=rmw_fastrtps_cpp + +# Resolve plugin path and patch config +PLUGIN_PATH=$(find /root/ws/install -name 'libcontainer_introspection.so' | head -1) +sed -i "s|__PLUGIN_PATH__|$PLUGIN_PATH|" /etc/ros2_medkit/gateway_docker_params.yaml + +mkdir -p /var/lib/ros2_medkit/rosbags + +# Start demo nodes in background +ros2 run ros2_medkit_integration_tests demo_engine_temp_sensor \ + --ros-args -r __ns:=/powertrain/engine -r __node:=temp_sensor & +ros2 run ros2_medkit_integration_tests demo_rpm_sensor \ + --ros-args -r __ns:=/powertrain/engine -r __node:=rpm_sensor & + +# Start gateway in foreground +exec ros2 run ros2_medkit_gateway gateway_node \ + --ros-args --params-file /etc/ros2_medkit/gateway_docker_params.yaml +BASH +RUN chmod +x /entrypoint.sh + +EXPOSE 8080 + +CMD ["/entrypoint.sh"] diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.systemd b/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.systemd new file mode 100644 index 000000000..8c8e5da94 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.systemd @@ -0,0 +1,196 @@ +# 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. + +# Multi-stage build for systemd introspection integration tests. +# Stage 1: Build the colcon workspace (gateway + plugins + demo nodes). +# Stage 2: Runtime image with systemd as PID 1, demo nodes as systemd services. + +# ============================================================================ +# Stage 1: Builder +# ============================================================================ +FROM ros:jazzy-ros-base AS builder + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DISTRO=jazzy +ENV COLCON_WS=/root/ws + +# Build dependencies +RUN apt-get update && apt-get install -y \ + ros-jazzy-ament-lint-auto \ + ros-jazzy-ament-lint-common \ + ros-jazzy-ament-cmake-gtest \ + ros-jazzy-yaml-cpp-vendor \ + ros-jazzy-example-interfaces \ + python3-colcon-common-extensions \ + nlohmann-json3-dev \ + libcpp-httplib-dev \ + sqlite3 \ + libsqlite3-dev \ + libsystemd-dev \ + pkg-config \ + && rm -rf /var/lib/apt/lists/* + +# Copy source tree +WORKDIR ${COLCON_WS} +COPY cmake/ ${COLCON_WS}/cmake/ +COPY src/ros2_medkit_gateway/ ${COLCON_WS}/src/ros2_medkit_gateway/ +COPY src/ros2_medkit_serialization/ ${COLCON_WS}/src/ros2_medkit_serialization/ +COPY src/ros2_medkit_msgs/ ${COLCON_WS}/src/ros2_medkit_msgs/ +COPY src/ros2_medkit_fault_manager/ ${COLCON_WS}/src/ros2_medkit_fault_manager/ +COPY src/ros2_medkit_fault_reporter/ ${COLCON_WS}/src/ros2_medkit_fault_reporter/ +COPY src/ros2_medkit_diagnostic_bridge/ ${COLCON_WS}/src/ros2_medkit_diagnostic_bridge/ +COPY src/ros2_medkit_integration_tests/ ${COLCON_WS}/src/ros2_medkit_integration_tests/ +COPY src/ros2_medkit_discovery_plugins/ ${COLCON_WS}/src/ros2_medkit_discovery_plugins/ + +# Build everything (skip test targets to speed up the image build) +RUN bash -c "source /opt/ros/jazzy/setup.bash && \ + rosdep update && \ + rosdep install --from-paths src --ignore-src -r -y \ + --skip-keys='ament_cmake_clang_format ament_cmake_clang_tidy test_msgs sqlite3' && \ + colcon build --symlink-install --cmake-args -DBUILD_TESTING=OFF" + +# ============================================================================ +# Stage 2: Runtime with systemd +# ============================================================================ +FROM ros:jazzy-ros-base + +ENV DEBIAN_FRONTEND=noninteractive +ENV ROS_DISTRO=jazzy +ENV COLCON_WS=/root/ws + +# Install systemd and runtime dependencies +RUN apt-get update && apt-get install -y \ + systemd \ + systemd-sysv \ + dbus \ + ros-jazzy-yaml-cpp-vendor \ + ros-jazzy-example-interfaces \ + nlohmann-json3-dev \ + libcpp-httplib-dev \ + sqlite3 \ + libsqlite3-dev \ + libsystemd-dev \ + && rm -rf /var/lib/apt/lists/* + +# Remove unnecessary systemd services that interfere in containers +RUN rm -f /lib/systemd/system/multi-user.target.wants/* \ + /etc/systemd/system/*.wants/* \ + /lib/systemd/system/local-fs.target.wants/* \ + /lib/systemd/system/sockets.target.wants/*udev* \ + /lib/systemd/system/sockets.target.wants/*initctl* \ + /lib/systemd/system/basic.target.wants/* \ + /lib/systemd/system/anaconda.target.wants/* + +# Copy built workspace from builder +COPY --from=builder ${COLCON_WS}/install/ ${COLCON_WS}/install/ +COPY --from=builder ${COLCON_WS}/src/ros2_medkit_gateway/config/gateway_params.yaml \ + /etc/ros2_medkit/gateway_params.yaml + +# Create gateway config with systemd plugin enabled +RUN cat > /etc/ros2_medkit/gateway_docker_params.yaml <<'YAML' +ros2_medkit_gateway: + ros__parameters: + server: + host: "0.0.0.0" + port: 8080 + refresh_interval_ms: 2000 + cors: + allowed_origins: ["*"] + plugins: ["systemd_introspection"] + plugins.systemd_introspection.path: "__PLUGIN_PATH__" + plugins.systemd_introspection.pid_cache_ttl_seconds: 5 +YAML + +# Create ROS 2 environment setup script sourced by all services +RUN cat > /etc/ros2_medkit/env.sh <<'BASH' +#!/bin/bash +source /opt/ros/jazzy/setup.bash +source /root/ws/install/setup.bash +export RMW_IMPLEMENTATION=rmw_fastrtps_cpp +BASH +RUN chmod +x /etc/ros2_medkit/env.sh + +# --- systemd service: gateway --- +RUN cat > /etc/systemd/system/ros2-medkit-gateway.service <<'UNIT' +[Unit] +Description=ROS 2 Medkit Gateway with systemd introspection plugin +After=network.target dbus.service +Wants=dbus.service + +[Service] +Type=simple +ExecStartPre=/bin/bash -c "source /etc/ros2_medkit/env.sh && \ + PLUGIN_PATH=$(find /root/ws/install -name 'libsystemd_introspection.so' | head -1) && \ + sed -i \"s|__PLUGIN_PATH__|$PLUGIN_PATH|\" /etc/ros2_medkit/gateway_docker_params.yaml" +ExecStart=/bin/bash -c "source /etc/ros2_medkit/env.sh && \ + ros2 run ros2_medkit_gateway gateway_node \ + --ros-args --params-file /etc/ros2_medkit/gateway_docker_params.yaml" +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +UNIT + +# --- systemd service: temp_sensor demo node --- +RUN cat > /etc/systemd/system/ros2-demo-temp-sensor.service <<'UNIT' +[Unit] +Description=ROS 2 Demo - Engine Temperature Sensor +After=network.target + +[Service] +Type=simple +ExecStart=/bin/bash -c "source /etc/ros2_medkit/env.sh && \ + ros2 run ros2_medkit_integration_tests demo_engine_temp_sensor \ + --ros-args -r __ns:=/powertrain/engine -r __node:=temp_sensor" +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +UNIT + +# --- systemd service: rpm_sensor demo node --- +RUN cat > /etc/systemd/system/ros2-demo-rpm-sensor.service <<'UNIT' +[Unit] +Description=ROS 2 Demo - Engine RPM Sensor +After=network.target + +[Service] +Type=simple +ExecStart=/bin/bash -c "source /etc/ros2_medkit/env.sh && \ + ros2 run ros2_medkit_integration_tests demo_rpm_sensor \ + --ros-args -r __ns:=/powertrain/engine -r __node:=rpm_sensor" +Restart=on-failure +RestartSec=3 + +[Install] +WantedBy=multi-user.target +UNIT + +# Enable all services +RUN systemctl enable ros2-medkit-gateway.service \ + ros2-demo-temp-sensor.service \ + ros2-demo-rpm-sensor.service + +# Create rosbag storage dir +RUN mkdir -p /var/lib/ros2_medkit/rosbags + +EXPOSE 8080 + +STOPSIGNAL SIGRTMIN+3 +VOLUME ["/sys/fs/cgroup"] + +# systemd as PID 1 +CMD ["/sbin/init"] diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.container.yml b/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.container.yml new file mode 100644 index 000000000..0fe31deac --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.container.yml @@ -0,0 +1,33 @@ +# Copyright 2026 bburda +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +# Docker Compose for container introspection plugin integration tests. +# Runs gateway + demo nodes inside a resource-limited Docker container. +# The container plugin detects containerization via cgroup paths. + +services: + gateway-container: + build: + context: ../../../.. # worktree root (ros2_medkit) + dockerfile: src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.container + ports: + - "9210:8080" + mem_limit: 512m + cpus: 1.0 + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 20s diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.systemd.yml b/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.systemd.yml new file mode 100644 index 000000000..b0ffcbf9d --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/docker-compose.systemd.yml @@ -0,0 +1,43 @@ +# 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. + +# Docker Compose for systemd introspection plugin integration tests. +# Runs gateway + demo nodes inside a systemd-managed container. +# Requires: privileged mode, cgroup host namespace for full systemd support. +# WARNING: This compose file runs a PRIVILEGED container with host cgroup access. +# Only run on isolated CI runners or development machines. +# The privileged mode is required for systemd to function inside the container. +# Do NOT use this configuration in production or shared environments. + +services: + gateway-systemd: + build: + context: ../../../.. # worktree root (ros2_medkit) + dockerfile: src/ros2_medkit_integration_tests/test/docker/introspection/Dockerfile.systemd + privileged: true # Required for systemd as PID 1 + ports: + - "9200:8080" + tmpfs: + - /run + - /run/lock + volumes: + - /sys/fs/cgroup:/sys/fs/cgroup:rw + cgroupns: host + stop_signal: SIGRTMIN+3 # Proper systemd shutdown signal + healthcheck: + test: ["CMD", "curl", "-sf", "http://localhost:8080/api/v1/health"] + interval: 5s + timeout: 3s + retries: 12 + start_period: 30s diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/run_docker_tests.sh b/src/ros2_medkit_integration_tests/test/docker/introspection/run_docker_tests.sh new file mode 100755 index 000000000..8c15d4829 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/run_docker_tests.sh @@ -0,0 +1,162 @@ +#!/bin/bash +# 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. + +# Docker-based integration tests for Linux introspection plugins. +# +# These tests require Docker and docker compose. They build container images +# with the gateway + plugins + demo nodes, then run pytest against the +# exposed HTTP API from outside the container. +# +# Usage: +# ./run_docker_tests.sh # Run all tests (systemd + container) +# ./run_docker_tests.sh systemd # Run only systemd tests +# ./run_docker_tests.sh container # Run only container tests +# +# Requirements: +# - docker and docker compose (v2) +# - pytest and requests (pip install pytest requests) +# +# Port allocations (outside colcon test port range 9100+): +# - 9200: systemd test gateway +# - 9210: container test gateway + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +cd "$SCRIPT_DIR" + +TARGET="${1:-all}" +FAILED=0 + +cleanup() { + echo "" + echo "=== Cleaning up Docker containers ===" + docker compose -f docker-compose.systemd.yml down --timeout 10 2>/dev/null || true + docker compose -f docker-compose.container.yml down --timeout 10 2>/dev/null || true +} +trap cleanup EXIT + +wait_for_healthy() { + local compose_file="$1" + local service="$2" + local timeout="${3:-60}" + local deadline=$((SECONDS + timeout)) + + echo "Waiting for $service to be healthy (timeout: ${timeout}s)..." + while [ $SECONDS -lt $deadline ]; do + local health + health=$(docker compose -f "$compose_file" ps --format '{{.Health}}' "$service" 2>/dev/null || echo "") + if [ "$health" = "healthy" ]; then + echo "$service is healthy" + return 0 + fi + sleep 2 + done + echo "ERROR: $service did not become healthy within ${timeout}s" + echo "Container logs:" + docker compose -f "$compose_file" logs "$service" 2>/dev/null || true + return 1 +} + +run_systemd_tests() { + echo "" + echo "================================================================" + echo "=== Systemd Introspection Tests ===" + echo "================================================================" + echo "" + + echo "--- Building systemd test image ---" + docker compose -f docker-compose.systemd.yml build + + echo "" + echo "--- Starting systemd container ---" + docker compose -f docker-compose.systemd.yml up -d + + if ! wait_for_healthy docker-compose.systemd.yml gateway-systemd 90; then + echo "FAIL: systemd container did not start properly" + docker compose -f docker-compose.systemd.yml logs + docker compose -f docker-compose.systemd.yml down + return 1 + fi + + echo "" + echo "--- Running systemd tests ---" + if pytest test_systemd_introspection.py -v; then + echo "PASS: systemd tests" + else + echo "FAIL: systemd tests" + FAILED=1 + fi + + docker compose -f docker-compose.systemd.yml down --timeout 10 +} + +run_container_tests() { + echo "" + echo "================================================================" + echo "=== Container Introspection Tests ===" + echo "================================================================" + echo "" + + echo "--- Building container test image ---" + docker compose -f docker-compose.container.yml build + + echo "" + echo "--- Starting container ---" + docker compose -f docker-compose.container.yml up -d + + if ! wait_for_healthy docker-compose.container.yml gateway-container 60; then + echo "FAIL: container did not start properly" + docker compose -f docker-compose.container.yml logs + docker compose -f docker-compose.container.yml down + return 1 + fi + + echo "" + echo "--- Running container tests ---" + if pytest test_container_introspection.py -v; then + echo "PASS: container tests" + else + echo "FAIL: container tests" + FAILED=1 + fi + + docker compose -f docker-compose.container.yml down --timeout 10 +} + +case "$TARGET" in + systemd) + run_systemd_tests + ;; + container) + run_container_tests + ;; + all) + run_systemd_tests + run_container_tests + ;; + *) + echo "Usage: $0 [all|systemd|container]" + exit 1 + ;; +esac + +echo "" +if [ $FAILED -ne 0 ]; then + echo "=== SOME DOCKER INTEGRATION TESTS FAILED ===" + exit 1 +else + echo "=== All Docker integration tests passed ===" +fi diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py b/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py new file mode 100644 index 000000000..cb8df882c --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/test_container_introspection.py @@ -0,0 +1,287 @@ +#!/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. + +"""Docker-based integration tests for the container introspection plugin. + +These tests run OUTSIDE the Docker container, talking to the gateway +inside it via localhost:9210. The container must be started first with: + + docker compose -f docker-compose.container.yml up -d + +The gateway runs inside a Docker container with resource limits (512MB RAM, +1 CPU). The container plugin detects containerization by reading cgroup +paths and extracts container ID, runtime, and resource limits. +""" + +import os +import re +import time + +import pytest +import requests + +BASE_URL = os.environ.get( + "CONTAINER_TEST_BASE_URL", "http://localhost:9210/api/v1" +) +STARTUP_TIMEOUT = 60 +POLL_INTERVAL = 1 +POLL_RETRIES = 20 + + +@pytest.fixture(scope="module", autouse=True) +def wait_for_gateway(): + """Wait for gateway health and app discovery inside Docker container.""" + deadline = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < deadline: + try: + r = requests.get(f"{BASE_URL}/health", timeout=2) + if r.status_code == 200: + # Also wait for at least 2 apps (temp_sensor + rpm_sensor) + r2 = requests.get(f"{BASE_URL}/apps", timeout=2) + if r2.status_code == 200: + apps = r2.json().get("items", []) + if len(apps) >= 2: + return + except requests.RequestException: + pass + time.sleep(1) + pytest.fail(f"Gateway not ready after {STARTUP_TIMEOUT}s") + + +def _get_app_ids(): + """Get all discovered app IDs.""" + r = requests.get(f"{BASE_URL}/apps", timeout=5) + r.raise_for_status() + items = r.json().get("items", []) + assert len(items) > 0, "No apps discovered" + return [item["id"] for item in items] + + +def _get_first_app_id(): + """Get first discovered app ID.""" + return _get_app_ids()[0] + + +def _get_first_component_id(): + """Get first discovered component ID.""" + r = requests.get(f"{BASE_URL}/components", timeout=5) + r.raise_for_status() + items = r.json().get("items", []) + assert len(items) > 0, "No components discovered" + return items[0]["id"] + + +def _poll_endpoint(url, retries=POLL_RETRIES, interval=POLL_INTERVAL): + """Poll an endpoint until it returns 200. Returns (status_code, json).""" + for _ in range(retries): + r = requests.get(url, timeout=5) + if r.status_code == 200: + return r.status_code, r.json() + time.sleep(interval) + return r.status_code, None + + +class TestContainerAppEndpoint: + """Tests for GET /apps/{id}/x-medkit-container.""" + + def test_returns_container_info(self): + """Container endpoint returns info when running inside Docker. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + assert status == 200, ( + f"container endpoint not available for {app_id}" + ) + assert "container_id" in data + assert "runtime" in data + + def test_container_id_is_64_char_hex(self): + """Container ID should be a full 64-character hex SHA-256 hash. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + assert status == 200 + cid = data["container_id"] + assert len(cid) == 64, ( + f"Expected 64-char container ID, got {len(cid)}: {cid}" + ) + assert re.match(r"^[0-9a-f]{64}$", cid), ( + f"Container ID is not valid hex: {cid}" + ) + + def test_runtime_is_docker(self): + """Runtime should be detected as 'docker' when running in Docker. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + assert status == 200 + assert data["runtime"] == "docker", ( + f"Expected runtime 'docker', got '{data['runtime']}'" + ) + + def test_memory_limit_detected(self): + """Container memory limit should be detected from cgroup. + + docker-compose.container.yml sets 512MB = 536870912 bytes. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + assert status == 200 + assert "memory_limit_bytes" in data, ( + f"memory_limit_bytes missing from response: {data}" + ) + # 512MB = 536870912 bytes + assert data["memory_limit_bytes"] == 536870912, ( + f"Expected 536870912 bytes (512MB), " + f"got {data['memory_limit_bytes']}" + ) + + def test_cpu_quota_detected(self): + """Container CPU quota should be detected from cgroup. + + docker-compose.container.yml sets cpus: 1.0. + For cgroup v2 with cpus=1.0, the quota is typically 100000us + with a period of 100000us. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + assert status == 200 + assert "cpu_quota_us" in data, ( + f"cpu_quota_us missing from response: {data}" + ) + assert "cpu_period_us" in data, ( + f"cpu_period_us missing from response: {data}" + ) + # cpus: 1.0 means quota/period = 1.0 + ratio = data["cpu_quota_us"] / data["cpu_period_us"] + assert abs(ratio - 1.0) < 0.01, ( + f"Expected CPU ratio ~1.0, got {ratio} " + f"(quota={data['cpu_quota_us']}, " + f"period={data['cpu_period_us']})" + ) + + def test_all_apps_same_container(self): + """All apps in the same container should report the same container ID. + + @verifies REQ_INTEROP_003 + """ + app_ids = _get_app_ids() + container_ids = set() + for app_id in app_ids: + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-container" + ) + if status == 200 and "container_id" in data: + container_ids.add(data["container_id"]) + # All apps run in the same Docker container + assert len(container_ids) == 1, ( + f"Expected 1 unique container ID, got {len(container_ids)}: " + f"{container_ids}" + ) + + +class TestContainerComponentEndpoint: + """Tests for GET /components/{id}/x-medkit-container.""" + + def test_returns_containers_aggregation(self): + """Component endpoint returns aggregated container info. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-container" + ) + assert status == 200, ( + f"container component endpoint not available for {comp_id}" + ) + assert "containers" in data + assert isinstance(data["containers"], list) + + def test_containers_include_node_ids(self): + """Each container in the aggregation includes node_ids. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-container" + ) + assert status == 200 + if len(data["containers"]) > 0: + container = data["containers"][0] + assert "node_ids" in container + assert isinstance(container["node_ids"], list) + assert len(container["node_ids"]) > 0 + + def test_containers_include_runtime(self): + """Each container in the aggregation includes runtime info. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-container" + ) + assert status == 200 + for container in data["containers"]: + assert "runtime" in container + assert "container_id" in container + + +class TestContainerErrorHandling: + """Tests for error cases on container endpoints.""" + + def test_nonexistent_app_returns_404(self): + """Requesting container info for a non-existent app returns 404. + + @verifies REQ_INTEROP_003 + """ + r = requests.get( + f"{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-container", + timeout=5, + ) + assert r.status_code == 404 + + def test_nonexistent_component_returns_404(self): + """Requesting container info for a non-existent component returns 404. + + @verifies REQ_INTEROP_003 + """ + r = requests.get( + f"{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-container", + timeout=5, + ) + assert r.status_code == 404 diff --git a/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py b/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py new file mode 100644 index 000000000..d984bfc7e --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/docker/introspection/test_systemd_introspection.py @@ -0,0 +1,228 @@ +#!/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. + +"""Docker-based integration tests for the systemd introspection plugin. + +These tests run OUTSIDE the Docker container, talking to the gateway +inside it via localhost:9200. The container must be started first with: + + docker compose -f docker-compose.systemd.yml up -d + +The gateway runs inside a container with systemd as PID 1, and demo nodes +are managed as systemd services. The systemd plugin queries unit properties +via sd-bus and maps PIDs to units via sd_pid_get_unit(). +""" + +import os +import time + +import pytest +import requests + +BASE_URL = os.environ.get( + "SYSTEMD_TEST_BASE_URL", "http://localhost:9200/api/v1" +) +STARTUP_TIMEOUT = 60 +POLL_INTERVAL = 1 +POLL_RETRIES = 20 + + +@pytest.fixture(scope="module", autouse=True) +def wait_for_gateway(): + """Wait for gateway health and app discovery inside Docker container.""" + deadline = time.monotonic() + STARTUP_TIMEOUT + while time.monotonic() < deadline: + try: + r = requests.get(f"{BASE_URL}/health", timeout=2) + if r.status_code == 200: + # Also wait for at least 2 apps (temp_sensor + rpm_sensor) + r2 = requests.get(f"{BASE_URL}/apps", timeout=2) + if r2.status_code == 200: + apps = r2.json().get("items", []) + if len(apps) >= 2: + return + except requests.RequestException: + pass + time.sleep(1) + pytest.fail(f"Gateway not ready after {STARTUP_TIMEOUT}s") + + +def _get_app_ids(): + """Get all discovered app IDs.""" + r = requests.get(f"{BASE_URL}/apps", timeout=5) + r.raise_for_status() + items = r.json().get("items", []) + assert len(items) > 0, "No apps discovered" + return [item["id"] for item in items] + + +def _get_first_app_id(): + """Get first discovered app ID.""" + return _get_app_ids()[0] + + +def _get_first_component_id(): + """Get first discovered component ID.""" + r = requests.get(f"{BASE_URL}/components", timeout=5) + r.raise_for_status() + items = r.json().get("items", []) + assert len(items) > 0, "No components discovered" + return items[0]["id"] + + +def _poll_endpoint(url, retries=POLL_RETRIES, interval=POLL_INTERVAL): + """Poll an endpoint until it returns 200. Returns (status_code, json).""" + for _ in range(retries): + r = requests.get(url, timeout=5) + if r.status_code == 200: + return r.status_code, r.json() + time.sleep(interval) + return r.status_code, None + + +class TestSystemdAppEndpoint: + """Tests for GET /apps/{id}/x-medkit-systemd.""" + + def test_returns_unit_info(self): + """Systemd endpoint returns unit info with active_state. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + ) + assert status == 200, ( + f"systemd endpoint not available for {app_id}" + ) + assert "unit" in data + assert data["unit"].endswith(".service") + assert data["active_state"] == "active" + assert "sub_state" in data + assert data["sub_state"] == "running" + + def test_returns_unit_type(self): + """Systemd endpoint includes unit_type field. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + ) + assert status == 200 + assert data["unit_type"] == "service" + + def test_returns_restart_count(self): + """Systemd endpoint includes restart_count (NRestarts property). + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + ) + assert status == 200 + assert "restart_count" in data + assert isinstance(data["restart_count"], int) + assert data["restart_count"] >= 0 + + def test_returns_watchdog_usec(self): + """Systemd endpoint includes watchdog_usec field. + + @verifies REQ_INTEROP_003 + """ + app_id = _get_first_app_id() + status, data = _poll_endpoint( + f"{BASE_URL}/apps/{app_id}/x-medkit-systemd" + ) + assert status == 200 + assert "watchdog_usec" in data + assert isinstance(data["watchdog_usec"], int) + + +class TestSystemdComponentEndpoint: + """Tests for GET /components/{id}/x-medkit-systemd.""" + + def test_returns_units_aggregation(self): + """Component endpoint returns aggregated unit info for child apps. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + ) + assert status == 200, ( + f"systemd component endpoint not available for {comp_id}" + ) + assert "units" in data + assert isinstance(data["units"], list) + + def test_units_include_node_ids(self): + """Each unit in the aggregation includes node_ids listing the apps. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + ) + assert status == 200 + if len(data["units"]) > 0: + unit = data["units"][0] + assert "node_ids" in unit + assert isinstance(unit["node_ids"], list) + assert len(unit["node_ids"]) > 0 + + def test_units_have_active_state(self): + """Each unit in the aggregation includes active_state. + + @verifies REQ_INTEROP_003 + """ + comp_id = _get_first_component_id() + status, data = _poll_endpoint( + f"{BASE_URL}/components/{comp_id}/x-medkit-systemd" + ) + assert status == 200 + for unit in data["units"]: + assert "active_state" in unit + assert "unit" in unit + + +class TestSystemdErrorHandling: + """Tests for error cases on systemd endpoints.""" + + def test_nonexistent_app_returns_404(self): + """Requesting systemd info for a non-existent app returns 404. + + @verifies REQ_INTEROP_003 + """ + r = requests.get( + f"{BASE_URL}/apps/nonexistent_app_xyz/x-medkit-systemd", + timeout=5, + ) + assert r.status_code == 404 + + def test_nonexistent_component_returns_404(self): + """Requesting systemd info for a non-existent component returns 404. + + @verifies REQ_INTEROP_003 + """ + r = requests.get( + f"{BASE_URL}/components/nonexistent_comp_xyz/x-medkit-systemd", + timeout=5, + ) + assert r.status_code == 404 diff --git a/src/ros2_medkit_integration_tests/test/features/test_combined_introspection.test.py b/src/ros2_medkit_integration_tests/test/features/test_combined_introspection.test.py new file mode 100644 index 000000000..11c6c7b97 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_combined_introspection.test.py @@ -0,0 +1,174 @@ +#!/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 combined procfs + container introspection plugins. + +Validates that loading multiple introspection plugins simultaneously works +correctly. On a non-containerized host, procfs should return 200 while +container returns 404 - verifying route isolation between plugins. +""" + +import os +import time +import unittest + +import launch_testing +import requests + +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 create_test_launch + + +def _get_plugin_path(plugin_so_name): + """Get path to a plugin .so file installed by ros2_medkit_linux_introspection.""" + from ament_index_python.packages import get_package_prefix + + pkg_prefix = get_package_prefix('ros2_medkit_linux_introspection') + return os.path.join( + pkg_prefix, 'lib', 'ros2_medkit_linux_introspection', plugin_so_name + ) + + +def generate_test_description(): + """Launch gateway with both procfs and container plugins.""" + procfs_path = _get_plugin_path('libprocfs_introspection.so') + container_path = _get_plugin_path('libcontainer_introspection.so') + return create_test_launch( + demo_nodes=['temp_sensor', 'rpm_sensor'], + fault_manager=False, + gateway_params={ + 'plugins': ['procfs', 'container'], + 'plugins.procfs.path': procfs_path, + 'plugins.container.path': container_path, + }, + ) + + +class TestCombinedIntrospection(GatewayTestCase): + """Test procfs + container plugins loaded simultaneously. + + @verifies REQ_INTEROP_003 + """ + + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {'temp_sensor', 'rpm_sensor'} + + def _get_any_app_id(self): + """Get the first discovered app ID.""" + data = self.get_json('/apps') + items = data.get('items', []) + self.assertGreater(len(items), 0, 'No apps discovered') + return items[0]['id'] + + def _poll_procfs_app(self, app_id, timeout=20.0): + """Poll procfs endpoint until it returns valid data.""" + start = time.monotonic() + last_status = None + while time.monotonic() - start < timeout: + try: + r = requests.get( + f'{self.BASE_URL}/apps/{app_id}/x-medkit-procfs', + timeout=5, + ) + last_status = r.status_code + if r.status_code == 200: + return r.json() + except requests.exceptions.RequestException: + pass + time.sleep(1.0) + self.fail( + f'Procfs data not available for app {app_id} after {timeout}s ' + f'(last status: {last_status})' + ) + + def _poll_container_app(self, app_id, timeout=20.0): + """Poll container endpoint until it returns a definitive response. + + On a host (non-containerized), the endpoint returns 404 once the PID + cache is populated. Before that, it may return 503 (PID not found). + We wait for either 200 or 404 (both are valid settled states). + """ + start = time.monotonic() + last_status = None + while time.monotonic() - start < timeout: + try: + r = requests.get( + f'{self.BASE_URL}/apps/{app_id}/x-medkit-container', + timeout=5, + ) + last_status = r.status_code + if r.status_code in (200, 404): + return r + except requests.exceptions.RequestException: + pass + time.sleep(1.0) + self.fail( + f'Container endpoint did not settle for app {app_id} after {timeout}s ' + f'(last status: {last_status})' + ) + + def test_01_procfs_returns_200_on_host(self): + """Procfs plugin returns 200 with valid process data on host.""" + app_id = self._get_any_app_id() + data = self._poll_procfs_app(app_id) + + self.assertGreater(data['pid'], 0) + self.assertGreater(data['rss_bytes'], 0) + self.assertIsInstance(data['exe'], str) + self.assertTrue(len(data['exe']) > 0) + + def test_02_container_returns_404_on_host(self): + """Container plugin returns 404 'not containerized' on a host system.""" + app_id = self._get_any_app_id() + r = self._poll_container_app(app_id) + + self.assertEqual( + r.status_code, + 404, + f'Expected 404 on non-containerized host, got {r.status_code}: {r.text}', + ) + body = r.json() + # Vendor error codes are wrapped: error_code='vendor-error', vendor_code='x-medkit-*' + self.assertEqual(body.get('vendor_code'), 'x-medkit-not-containerized') + + def test_03_route_isolation(self): + """One plugin's error does not affect the other's success. + + Verify procfs still returns 200 even though container returns 404 + for the same entity - routes are isolated between plugins. + """ + app_id = self._get_any_app_id() + + # Container should be 404 (not containerized) + container_resp = self._poll_container_app(app_id) + self.assertEqual(container_resp.status_code, 404) + + # Procfs should still return 200 with valid data + procfs_data = self._poll_procfs_app(app_id) + self.assertGreater(procfs_data['pid'], 0) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + """Verify gateway exits cleanly.""" + + def test_exit_codes(self, proc_info): + for info in proc_info: + self.assertIn( + info.returncode, + ALLOWED_EXIT_CODES, + f'Process {info.process_name} exited with {info.returncode}', + ) 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 69e41c7d5..f67cce1bd 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 @@ -60,38 +60,32 @@ class TestLegacyDiscoveryMode(GatewayTestCase): def test_each_node_has_own_component(self): """Each node should become its own Component (no synthetic grouping).""" - required_nodes = ('temp_sensor', 'rpm_sensor', 'pressure_sensor') + expected = ['temp_sensor', 'rpm_sensor', 'pressure_sensor'] data = self.poll_endpoint_until( '/components', lambda d: d if all( - any(node_name in c['id'] for c in d.get('items', [])) - for node_name in required_nodes + any(name in c['id'] for c in d.get('items', [])) + for name in expected ) else None, timeout=60.0, ) component_ids = [c['id'] for c in data['items']] # Each demo node should appear as a component - # Node names: temp_sensor, rpm_sensor, pressure_sensor - self.assertTrue( - any('temp_sensor' in cid for cid in component_ids), - f"temp_sensor not found in components: {component_ids}", - ) - self.assertTrue( - any('rpm_sensor' in cid for cid in component_ids), - f"rpm_sensor not found in components: {component_ids}", - ) - self.assertTrue( - any('pressure_sensor' in cid for cid in component_ids), - f"pressure_sensor not found in components: {component_ids}", - ) + for name in expected: + self.assertTrue( + any(name in cid for cid in component_ids), + f"{name} not found in components: {component_ids}", + ) def test_no_synthetic_namespace_components(self): """No synthetic components from namespace grouping should exist.""" + expected = ['temp_sensor', 'rpm_sensor', 'pressure_sensor'] data = self.poll_endpoint_until( '/components', - lambda d: d if any( - 'temp_sensor' in c['id'] for c in d.get('items', []) + lambda d: d if all( + any(name in c['id'] for c in d.get('items', [])) + for name in expected ) else None, timeout=60.0, ) diff --git a/src/ros2_medkit_integration_tests/test/features/test_procfs_introspection.test.py b/src/ros2_medkit_integration_tests/test/features/test_procfs_introspection.test.py new file mode 100644 index 000000000..a2484a481 --- /dev/null +++ b/src/ros2_medkit_integration_tests/test/features/test_procfs_introspection.test.py @@ -0,0 +1,195 @@ +#!/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 procfs introspection plugin. + +Validates that the procfs plugin correctly exposes per-app and per-component +process info (PID, RSS, thread count, etc.) via vendor extension endpoints. +Exercises the PID cache lookup, /proc reading, and Component-level +aggregation across child apps. +""" + +import os +import time +import unittest + +import launch_testing +import requests + +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 create_test_launch + + +def _get_plugin_path(plugin_so_name): + """Get path to a plugin .so file installed by ros2_medkit_linux_introspection.""" + from ament_index_python.packages import get_package_prefix + + pkg_prefix = get_package_prefix('ros2_medkit_linux_introspection') + return os.path.join( + pkg_prefix, 'lib', 'ros2_medkit_linux_introspection', plugin_so_name + ) + + +def generate_test_description(): + """Launch gateway with the procfs plugin and demo nodes.""" + plugin_path = _get_plugin_path('libprocfs_introspection.so') + return create_test_launch( + demo_nodes=['temp_sensor', 'rpm_sensor'], + fault_manager=False, + gateway_params={ + 'plugins': ['procfs'], + 'plugins.procfs.path': plugin_path, + }, + ) + + +class TestProcfsIntrospection(GatewayTestCase): + """Test procfs plugin vendor extension endpoints. + + @verifies REQ_INTEROP_003 + """ + + MIN_EXPECTED_APPS = 2 + REQUIRED_APPS = {'temp_sensor', 'rpm_sensor'} + + def _get_any_app_id(self): + """Get the first discovered app ID.""" + data = self.get_json('/apps') + items = data.get('items', []) + self.assertGreater(len(items), 0, 'No apps discovered') + return items[0]['id'] + + def _get_any_component_id(self): + """Get the first discovered component ID.""" + data = self.get_json('/components') + items = data.get('items', []) + self.assertGreater(len(items), 0, 'No components discovered') + return items[0]['id'] + + def _poll_procfs_app(self, app_id, timeout=20.0): + """Poll procfs endpoint until it returns valid data. + + The PID cache may not be populated on the first request (503), + so we retry until data arrives. + """ + start = time.monotonic() + last_status = None + while time.monotonic() - start < timeout: + try: + r = requests.get( + f'{self.BASE_URL}/apps/{app_id}/x-medkit-procfs', + timeout=5, + ) + last_status = r.status_code + if r.status_code == 200: + return r.json() + except requests.exceptions.RequestException: + pass + time.sleep(1.0) + self.fail( + f'Procfs data not available for app {app_id} after {timeout}s ' + f'(last status: {last_status})' + ) + + def _poll_procfs_component(self, comp_id, timeout=20.0): + """Poll procfs component endpoint until it returns processes.""" + start = time.monotonic() + last_status = None + while time.monotonic() - start < timeout: + try: + r = requests.get( + f'{self.BASE_URL}/components/{comp_id}/x-medkit-procfs', + timeout=5, + ) + last_status = r.status_code + if r.status_code == 200: + data = r.json() + if data.get('processes'): + return data + except requests.exceptions.RequestException: + pass + time.sleep(1.0) + self.fail( + f'Procfs data not available for component {comp_id} after {timeout}s ' + f'(last status: {last_status})' + ) + + def test_01_app_procfs_returns_process_info(self): + """GET /apps/{id}/x-medkit-procfs returns process info with valid fields.""" + app_id = self._get_any_app_id() + data = self._poll_procfs_app(app_id) + + self.assertGreater(data['pid'], 0, 'PID should be positive') + self.assertGreater(data['rss_bytes'], 0, 'RSS should be positive') + self.assertIsInstance(data['exe'], str) + self.assertTrue(len(data['exe']) > 0, 'exe_path should be non-empty') + self.assertGreaterEqual(data['threads'], 1, 'Thread count should be >= 1') + + def test_02_component_procfs_returns_aggregation(self): + """GET /components/{id}/x-medkit-procfs returns aggregated processes.""" + comp_id = self._get_any_component_id() + data = self._poll_procfs_component(comp_id) + + processes = data['processes'] + self.assertGreater(len(processes), 0, 'Should have at least one process') + + for proc in processes: + self.assertGreater(proc['pid'], 0, 'PID should be positive') + self.assertGreater(proc['rss_bytes'], 0, 'RSS should be positive') + self.assertIn('node_ids', proc) + self.assertIsInstance(proc['node_ids'], list) + self.assertGreater( + len(proc['node_ids']), 0, 'node_ids should be non-empty' + ) + + def test_03_nonexistent_app_returns_404(self): + """GET /apps/nonexistent-entity/x-medkit-procfs returns 404.""" + r = requests.get( + f'{self.BASE_URL}/apps/nonexistent-entity/x-medkit-procfs', + timeout=5, + ) + self.assertEqual(r.status_code, 404) + + def test_04_capabilities_include_procfs(self): + """Entity capabilities include x-medkit-procfs with correct href.""" + app_id = self._get_any_app_id() + data = self.get_json(f'/apps/{app_id}') + capabilities = data.get('capabilities', []) + cap_names = [c['name'] for c in capabilities] + self.assertIn( + 'x-medkit-procfs', + cap_names, + f'x-medkit-procfs not in capabilities: {cap_names}', + ) + procfs_cap = next( + c for c in capabilities if c['name'] == 'x-medkit-procfs' + ) + self.assertIn( + f'/apps/{app_id}/x-medkit-procfs', procfs_cap['href'] + ) + + +@launch_testing.post_shutdown_test() +class TestShutdown(unittest.TestCase): + """Verify gateway exits cleanly.""" + + def test_exit_codes(self, proc_info): + for info in proc_info: + self.assertIn( + info.returncode, + ALLOWED_EXIT_CODES, + f'Process {info.process_name} exited with {info.returncode}', + )