Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions e2e/docker-compose.rosbag.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
# Stack for the rosbag-history specs: a gateway AND a fault manager, so a fault
# can actually own black-box recordings. docker-compose.yml next to this file
# runs a manifest-only gateway with no fault manager at all, which cannot
# produce a single bag; the two scenarios are kept apart rather than merged so
# neither has to carry the other's configuration.
services:
gateway:
# Overridable because the recording-id contract these specs assert on
# (ros2_medkit#620) is newer than any published tag: point this at a
# locally built image to run them before that lands. Once it is
# published, pin a digest here the way docker-compose.yml does.
image: ${E2E_ROSBAG_GATEWAY_IMAGE:-ghcr.io/selfpatch/ros2_medkit-jazzy:latest}
ports:
# Loopback only, and on its own port so this stack can run alongside
Comment thread
mfaferek93 marked this conversation as resolved.
# the scripts one without either stealing the other's.
- '127.0.0.1:${E2E_ROSBAG_GATEWAY_PORT:-8081}:8080'
volumes:
- ./gateway/rosbag-params.yaml:/e2e/params.yaml:ro
- ./gateway/seed_recordings.py:/e2e/seed_recordings.py:ro
Comment thread
mfaferek93 marked this conversation as resolved.
- e2e-bags:/e2e-bags
entrypoint: ['/bin/bash', '-lc']
command:
- >
source /opt/ros/jazzy/setup.bash &&
Comment thread
mfaferek93 marked this conversation as resolved.
Outdated
source /home/medkit/ws/install/setup.bash &&
ros2 run ros2_medkit_fault_manager fault_manager_node
--ros-args --params-file /e2e/params.yaml &
ros2 run ros2_medkit_gateway gateway_node
--ros-args --params-file /e2e/params.yaml
depends_on:
init-bags:
condition: service_completed_successfully
# The gateway image runs as uid 999 and a fresh named volume is root-owned,
# so the fault manager could not write a bag into it. Same one-shot chown
# the scripts stack does for its upload volume.
init-bags:
image: ${E2E_ROSBAG_GATEWAY_IMAGE:-ghcr.io/selfpatch/ros2_medkit-jazzy:latest}
user: root
volumes:
- e2e-bags:/e2e-bags
entrypoint: ['chown', '-R', '999:999', '/e2e-bags']
volumes:
e2e-bags:
36 changes: 36 additions & 0 deletions e2e/gateway/rosbag-params.yaml
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Gateway + fault manager for the rosbag-history scenario.
#
# Separate from params.yaml on purpose: that stack is manifest-only with script
# uploads and no fault manager at all, and this one needs the opposite - live
# ROS discovery so the seeded fault's reporting source resolves to an app, and a
# fault manager configured to keep a HISTORY of black-box recordings rather than
# overwriting on every re-confirmation.
/**:
ros__parameters:
server:
host: '0.0.0.0'
port: 8080
cors:
# Same reasoning as params.yaml: the browser's origin is the dev
# server, not the gateway, and E2E_APP_URL is overridable.
allowed_origins:
- '*'
# Rosbag retention. 3 leaves headroom above the two occurrences the seed
# drives, so a failing spec means "a recording was lost", not "the cap
# trimmed one".
snapshots:
rosbag:
enabled: true
duration_sec: 2.0
duration_after_sec: 0.5
include_topics: ['/e2e/probe']
format: 'mcap'
storage_path: '/e2e-bags'
max_bags_per_fault: 3
# Acknowledging a fault must not delete the evidence it just
# produced - the scenario is confirm, acknowledge, confirm again.
auto_cleanup: false
lazy_start: false
confirmation_threshold: -1
storage_type: 'sqlite'
database_path: '/e2e-bags/faults.db'
124 changes: 124 additions & 0 deletions e2e/gateway/seed_recordings.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
#!/usr/bin/env python3
# Copyright 2026 mfaferek93
#
# 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.

"""Leave one fault holding two black-box recordings, for the browser to click on.

Everything below the fault report is real: the fault manager runs its own
capture, records a topic that is genuinely being published, and writes two
separate bags to disk. Only the trigger is a service call rather than a sensor
detecting its own misconfiguration - the subject of these specs is the web UI,
and the demo nodes that detect faults on their own are not shipped in the
gateway image.

The fault is confirmed, acknowledged, then confirmed again: that is the sequence
that used to leave a single recording behind, because the second one overwrote
the first (ros2_medkit#620).
"""

import sys
import time

import rclpy
from rclpy.node import Node
from rclpy.qos import HistoryPolicy, QoSProfile, ReliabilityPolicy
from ros2_medkit_msgs.msg import Fault
from ros2_medkit_msgs.srv import ClearFault, ReportFault
from std_msgs.msg import Float32

FAULT_CODE = 'E2E_FLAPPING_SENSOR'
SOURCE_ID = '/e2e/probe_publisher'
Comment thread
mfaferek93 marked this conversation as resolved.
Outdated
PROBE_TOPIC = '/e2e/probe'
# Must exceed the configured duration_sec so the ring buffer holds a full window
# before each confirmation; a bag flushed from an empty buffer has no content.
FILL_SECONDS = 3.0


class Seeder(Node):
def __init__(self):
super().__init__('e2e_rosbag_seeder')
qos = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=10,
)
self.pub = self.create_publisher(Float32, PROBE_TOPIC, qos)
self.report = self.create_client(ReportFault, '/fault_manager/report_fault')
self.clear = self.create_client(ClearFault, '/fault_manager/clear_fault')

def wait_for_services(self, timeout=90.0):
for client, name in ((self.report, 'report_fault'), (self.clear, 'clear_fault')):
if not client.wait_for_service(timeout_sec=timeout):
raise SystemExit(f'{name} service never appeared')

def publish_for(self, seconds, rate_hz=20.0):
msg = Float32()
msg.data = 1.0
deadline = time.time() + seconds
period = 1.0 / rate_hz
while time.time() < deadline:
self.pub.publish(msg)
rclpy.spin_once(self, timeout_sec=0.0)
time.sleep(period)

def call(self, client, request):
future = client.call_async(request)
rclpy.spin_until_future_complete(self, future, timeout_sec=20.0)
if future.result() is None:
raise SystemExit('service call timed out')
return future.result()

def confirm(self):
request = ReportFault.Request()
request.fault_code = FAULT_CODE
request.event_type = ReportFault.Request.EVENT_FAILED
request.severity = Fault.SEVERITY_ERROR
request.description = 'Intermittent sensor dropout seen twice'
request.source_id = SOURCE_ID
return self.call(self.report, request)

def acknowledge(self):
request = ClearFault.Request()
request.fault_code = FAULT_CODE
return self.call(self.clear, request)


def main():
rclpy.init()
node = Seeder()
node.wait_for_services()

# First occurrence.
node.publish_for(FILL_SECONDS)
node.confirm()
node.publish_for(FILL_SECONDS) # post-roll window, then finalize
node.acknowledge()

# Second occurrence. Before #620 this one replaced the first recording
# outright, so the fault ended up with exactly one bag either way.
node.publish_for(FILL_SECONDS)
node.confirm()
node.publish_for(FILL_SECONDS)

# Deliberately NOT acknowledged: a cleared fault drops out of the default
# CONFIRMED-only listing, so acknowledging this one too would leave the specs
# with two bags on disk and no fault on screen pointing at them.
print('SEEDED', flush=True)
node.destroy_node()
rclpy.shutdown()
return 0


if __name__ == '__main__':
sys.exit(main())
Loading
Loading