Spaces:
Paused
Paused
Chandra Kiran commited on
Add a ROS 2 sandbox that builds, lints, tests and launches packages
Browse filesworker/ros/Dockerfile (humble/jazzy) carries colcon, the ament linters, launch_testing,
ros2_control, Nav2, MoveIt 2, xacro, robot_state_publisher, tf2 and rosbag2 MCAP.
worker/ros_runner.py builds the package, runs the linters and tests, launches it for
10 s while recording every topic, and reports what came up or crashed. CI checks four
sample packages (good Python, good C++, one that does not compile, one that crashes)
in both distros. On Modal it runs without network or secrets.
- .github/workflows/ci.yml +53 -0
- worker/modal_app.py +46 -0
- worker/ros/Dockerfile +42 -0
- worker/ros/samples/broken_cpp/rosdiff_broken_cpp/CMakeLists.txt +9 -0
- worker/ros/samples/broken_cpp/rosdiff_broken_cpp/package.xml +14 -0
- worker/ros/samples/broken_cpp/rosdiff_broken_cpp/src/broken.cpp +11 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/launch/heartbeat.launch.py +6 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/package.xml +14 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/resource/rosdiff_crash_py +0 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/__init__.py +0 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/heartbeat.py +11 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/setup.cfg +4 -0
- worker/ros/samples/crash_py/rosdiff_crash_py/setup.py +23 -0
- worker/ros/samples/good_cpp/rosdiff_sample_cpp/CMakeLists.txt +21 -0
- worker/ros/samples/good_cpp/rosdiff_sample_cpp/launch/twist.launch.xml +3 -0
- worker/ros/samples/good_cpp/rosdiff_sample_cpp/package.xml +18 -0
- worker/ros/samples/good_cpp/rosdiff_sample_cpp/src/twist_source.cpp +37 -0
- worker/ros/samples/good_py/rosdiff_sample_py/launch/heartbeat.launch.py +6 -0
- worker/ros/samples/good_py/rosdiff_sample_py/package.xml +16 -0
- worker/ros/samples/good_py/rosdiff_sample_py/resource/rosdiff_sample_py +0 -0
- worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/__init__.py +0 -0
- worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/heartbeat.py +28 -0
- worker/ros/samples/good_py/rosdiff_sample_py/setup.cfg +4 -0
- worker/ros/samples/good_py/rosdiff_sample_py/setup.py +23 -0
- worker/ros_runner.py +414 -0
.github/workflows/ci.yml
CHANGED
|
@@ -68,3 +68,56 @@ jobs:
|
|
| 68 |
name: worker-smoke-output
|
| 69 |
path: ci-out/runs/ci1
|
| 70 |
if-no-files-found: warn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
name: worker-smoke-output
|
| 69 |
path: ci-out/runs/ci1
|
| 70 |
if-no-files-found: warn
|
| 71 |
+
|
| 72 |
+
ros-sandbox:
|
| 73 |
+
name: ROS 2 sandbox (${{ matrix.distro }})
|
| 74 |
+
runs-on: ubuntu-24.04
|
| 75 |
+
strategy:
|
| 76 |
+
fail-fast: false
|
| 77 |
+
matrix:
|
| 78 |
+
distro: [humble, jazzy]
|
| 79 |
+
steps:
|
| 80 |
+
- uses: actions/checkout@v4
|
| 81 |
+
- name: Build
|
| 82 |
+
run: docker build -f worker/ros/Dockerfile --build-arg ROS_DISTRO=${{ matrix.distro }} -t rosdiff-ros:ci .
|
| 83 |
+
- name: Check the sample packages (build, lint, test, launch, bag)
|
| 84 |
+
run: |
|
| 85 |
+
mkdir -p ros-out && chmod 777 ros-out
|
| 86 |
+
docker run --rm -i -v "$PWD/ros-out:/out" rosdiff-ros:ci python3 - <<'EOF'
|
| 87 |
+
import json
|
| 88 |
+
from pathlib import Path
|
| 89 |
+
from worker.ros_runner import check
|
| 90 |
+
|
| 91 |
+
def run(name):
|
| 92 |
+
root = Path("/app/samples") / name
|
| 93 |
+
files = {p.relative_to(root).as_posix(): p.read_text() for p in root.rglob("*") if p.is_file()}
|
| 94 |
+
r = check(files, "${{ matrix.distro }}", Path("/out") / name)
|
| 95 |
+
print(f"===== {name}: {r['headline']}")
|
| 96 |
+
print(json.dumps(r, indent=2)[:4000])
|
| 97 |
+
return r
|
| 98 |
+
|
| 99 |
+
r = run("good_py")
|
| 100 |
+
assert r["passed"] and r["build"]["ok"] and r["launch"]["ok"], r
|
| 101 |
+
assert "/heartbeat" in r["launch"]["nodes"], r["launch"]
|
| 102 |
+
assert any(t.startswith("/heartbeat ") for t in r["launch"]["topics"]), r["launch"]
|
| 103 |
+
assert r.get("bag_bytes", 0) > 0 and Path("/out/good_py/bag.mcap").is_file(), r
|
| 104 |
+
|
| 105 |
+
r = run("good_cpp")
|
| 106 |
+
assert r["passed"] and "/twist_source" in r["launch"]["nodes"], r
|
| 107 |
+
assert r["test"]["ran"] and r["test"]["tests"] > 0, r["test"]
|
| 108 |
+
|
| 109 |
+
r = run("broken_cpp")
|
| 110 |
+
assert not r["passed"] and not r["build"]["ok"], r
|
| 111 |
+
assert any("speed" in e for e in r["build"]["errors"]), r["build"]
|
| 112 |
+
|
| 113 |
+
r = run("crash_py")
|
| 114 |
+
assert r["build"]["ok"] and not r["launch"]["ok"] and r["launch"]["crashed"], r["launch"]
|
| 115 |
+
print("all sample checks behaved as expected")
|
| 116 |
+
EOF
|
| 117 |
+
- uses: actions/upload-artifact@v4
|
| 118 |
+
if: always()
|
| 119 |
+
with:
|
| 120 |
+
name: ros-sandbox-${{ matrix.distro }}
|
| 121 |
+
path: ros-out
|
| 122 |
+
if-no-files-found: warn
|
| 123 |
+
|
worker/modal_app.py
CHANGED
|
@@ -59,3 +59,49 @@ def run_simulation(job_input: dict) -> dict:
|
|
| 59 |
from worker.handler import handler
|
| 60 |
|
| 61 |
return handler({"input": job_input})
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
from worker.handler import handler
|
| 60 |
|
| 61 |
return handler({"input": job_input})
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
# --------------------------------------------------------------------------- ROS 2 sandbox (/code/check)
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def _ros_image(distro: str) -> modal.Image:
|
| 68 |
+
# Same Dockerfile CI builds and tests; Modal's own Python is added alongside the image's (the checks call
|
| 69 |
+
# ROS through bash with a clean environment, so the two never mix).
|
| 70 |
+
return modal.Image.from_dockerfile(
|
| 71 |
+
str(Path(__file__).parent / "ros" / "Dockerfile"),
|
| 72 |
+
context_dir=str(Path(__file__).parent.parent),
|
| 73 |
+
build_args={"ROS_DISTRO": distro},
|
| 74 |
+
add_python="3.11",
|
| 75 |
+
).add_local_python_source("worker")
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
# Generated code is untrusted: no network, no secrets. Results come back inline and the API stores them.
|
| 79 |
+
_ROS_SANDBOX = dict(
|
| 80 |
+
cpu=2.0,
|
| 81 |
+
memory=4096,
|
| 82 |
+
timeout=900,
|
| 83 |
+
max_containers=int(os.environ.get("MODAL_MAX_CONTAINERS", "2")),
|
| 84 |
+
scaledown_window=20,
|
| 85 |
+
block_network=True,
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
@app.function(image=_ros_image("humble"), **_ROS_SANDBOX)
|
| 90 |
+
def ros_check_humble(job_input: dict) -> dict:
|
| 91 |
+
return _ros_check(job_input, "humble")
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
@app.function(image=_ros_image("jazzy"), **_ROS_SANDBOX)
|
| 95 |
+
def ros_check_jazzy(job_input: dict) -> dict:
|
| 96 |
+
return _ros_check(job_input, "jazzy")
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _ros_check(job_input: dict, distro: str) -> dict:
|
| 100 |
+
import traceback
|
| 101 |
+
|
| 102 |
+
from worker.ros_runner import run_check_job
|
| 103 |
+
|
| 104 |
+
try:
|
| 105 |
+
return run_check_job(job_input, distro)
|
| 106 |
+
except Exception as e:
|
| 107 |
+
return {"error": f"{type(e).__name__}: {e}", "traceback": traceback.format_exc()[-2000:]}
|
worker/ros/Dockerfile
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ROS 2 sandbox for /code/check: build, lint, test and launch generated packages.
|
| 2 |
+
#
|
| 3 |
+
# docker build -f worker/ros/Dockerfile --build-arg ROS_DISTRO=jazzy -t rosdiff-ros:jazzy .
|
| 4 |
+
#
|
| 5 |
+
# The tools most ROS 2 projects depend on are installed, so generated code that uses them builds:
|
| 6 |
+
# colcon, ament linters, launch_testing, ros2_control (+ controllers), Nav2, MoveIt 2, xacro,
|
| 7 |
+
# robot_state_publisher, tf2, rosbag2 with MCAP storage. Modal adds its own Python on top (add_python).
|
| 8 |
+
ARG ROS_DISTRO=jazzy
|
| 9 |
+
FROM ros:${ROS_DISTRO}-ros-base
|
| 10 |
+
ARG ROS_DISTRO
|
| 11 |
+
|
| 12 |
+
ENV DEBIAN_FRONTEND=noninteractive PYTHONUNBUFFERED=1
|
| 13 |
+
|
| 14 |
+
RUN apt-get update && apt-get install -y --no-install-recommends \
|
| 15 |
+
build-essential cmake git python3-colcon-common-extensions python3-pytest python3-numpy \
|
| 16 |
+
ros-${ROS_DISTRO}-ament-lint-common \
|
| 17 |
+
ros-${ROS_DISTRO}-ament-cmake-pytest \
|
| 18 |
+
ros-${ROS_DISTRO}-launch-testing-ament-cmake \
|
| 19 |
+
ros-${ROS_DISTRO}-launch-testing-ros \
|
| 20 |
+
ros-${ROS_DISTRO}-ros2-control \
|
| 21 |
+
ros-${ROS_DISTRO}-ros2-controllers \
|
| 22 |
+
ros-${ROS_DISTRO}-navigation2 \
|
| 23 |
+
ros-${ROS_DISTRO}-moveit-core \
|
| 24 |
+
ros-${ROS_DISTRO}-moveit-ros-planning-interface \
|
| 25 |
+
ros-${ROS_DISTRO}-moveit-msgs \
|
| 26 |
+
ros-${ROS_DISTRO}-xacro \
|
| 27 |
+
ros-${ROS_DISTRO}-robot-state-publisher \
|
| 28 |
+
ros-${ROS_DISTRO}-joint-state-publisher \
|
| 29 |
+
ros-${ROS_DISTRO}-tf2-ros \
|
| 30 |
+
ros-${ROS_DISTRO}-tf2-geometry-msgs \
|
| 31 |
+
ros-${ROS_DISTRO}-example-interfaces \
|
| 32 |
+
ros-${ROS_DISTRO}-rosbag2-storage-mcap \
|
| 33 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 34 |
+
|
| 35 |
+
WORKDIR /app
|
| 36 |
+
COPY worker/__init__.py worker/ros_runner.py worker/
|
| 37 |
+
COPY worker/ros/samples /app/samples
|
| 38 |
+
|
| 39 |
+
# Fail the build if the tools cannot be found.
|
| 40 |
+
RUN bash -c "source /opt/ros/${ROS_DISTRO}/setup.bash && ros2 pkg prefix nav2_msgs && ros2 pkg prefix moveit_core \
|
| 41 |
+
&& ros2 pkg prefix controller_manager && ros2 pkg prefix xacro && ros2 pkg prefix rosbag2_storage_mcap \
|
| 42 |
+
&& which ament_flake8 ament_cpplint ament_cppcheck ament_xmllint colcon"
|
worker/ros/samples/broken_cpp/rosdiff_broken_cpp/CMakeLists.txt
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cmake_minimum_required(VERSION 3.8)
|
| 2 |
+
project(rosdiff_broken_cpp)
|
| 3 |
+
find_package(ament_cmake REQUIRED)
|
| 4 |
+
find_package(rclcpp REQUIRED)
|
| 5 |
+
find_package(geometry_msgs REQUIRED)
|
| 6 |
+
add_executable(broken src/broken.cpp)
|
| 7 |
+
ament_target_dependencies(broken rclcpp geometry_msgs)
|
| 8 |
+
install(TARGETS broken DESTINATION lib/${PROJECT_NAME})
|
| 9 |
+
ament_package()
|
worker/ros/samples/broken_cpp/rosdiff_broken_cpp/package.xml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0"?>
|
| 2 |
+
<package format="3">
|
| 3 |
+
<name>rosdiff_broken_cpp</name>
|
| 4 |
+
<version>0.1.0</version>
|
| 5 |
+
<maintainer email="dev@example.com">dev</maintainer>
|
| 6 |
+
<license>Apache-2.0</license>
|
| 7 |
+
<buildtool_depend>ament_cmake</buildtool_depend>
|
| 8 |
+
<depend>rclcpp</depend>
|
| 9 |
+
<depend>geometry_msgs</depend>
|
| 10 |
+
<exec_depend>launch_ros</exec_depend>
|
| 11 |
+
<export>
|
| 12 |
+
<build_type>ament_cmake</build_type>
|
| 13 |
+
</export>
|
| 14 |
+
</package>
|
worker/ros/samples/broken_cpp/rosdiff_broken_cpp/src/broken.cpp
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Deliberately does not compile: Twist has no field "speed".
|
| 2 |
+
#include "geometry_msgs/msg/twist.hpp"
|
| 3 |
+
#include "rclcpp/rclcpp.hpp"
|
| 4 |
+
|
| 5 |
+
int main(int argc, char ** argv)
|
| 6 |
+
{
|
| 7 |
+
rclcpp::init(argc, argv);
|
| 8 |
+
geometry_msgs::msg::Twist msg;
|
| 9 |
+
msg.speed = 1.0;
|
| 10 |
+
return 0;
|
| 11 |
+
}
|
worker/ros/samples/crash_py/rosdiff_crash_py/launch/heartbeat.launch.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from launch import LaunchDescription
|
| 2 |
+
from launch_ros.actions import Node
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def generate_launch_description():
|
| 6 |
+
return LaunchDescription([Node(package="rosdiff_crash_py", executable="heartbeat", output="screen")])
|
worker/ros/samples/crash_py/rosdiff_crash_py/package.xml
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0"?>
|
| 2 |
+
<package format="3">
|
| 3 |
+
<name>rosdiff_crash_py</name>
|
| 4 |
+
<version>0.1.0</version>
|
| 5 |
+
<description>Sample: publishes a heartbeat and checks that Nav2 and ros2_control messages are importable.</description>
|
| 6 |
+
<maintainer email="dev@example.com">dev</maintainer>
|
| 7 |
+
<license>Apache-2.0</license>
|
| 8 |
+
<depend>rclpy</depend>
|
| 9 |
+
<depend>std_msgs</depend>
|
| 10 |
+
<exec_depend>launch_ros</exec_depend>
|
| 11 |
+
<export>
|
| 12 |
+
<build_type>ament_python</build_type>
|
| 13 |
+
</export>
|
| 14 |
+
</package>
|
worker/ros/samples/crash_py/rosdiff_crash_py/resource/rosdiff_crash_py
ADDED
|
File without changes
|
worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/__init__.py
ADDED
|
File without changes
|
worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/heartbeat.py
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Sample that crashes on start (a parameter it needs is missing), to check crash detection."""
|
| 2 |
+
|
| 3 |
+
import rclpy
|
| 4 |
+
from rclpy.node import Node
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
def main():
|
| 8 |
+
rclpy.init()
|
| 9 |
+
node = Node("crashes")
|
| 10 |
+
rate = node.get_parameter("rate").value # never declared: raises ParameterNotDeclaredException
|
| 11 |
+
print(rate)
|
worker/ros/samples/crash_py/rosdiff_crash_py/setup.cfg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[develop]
|
| 2 |
+
script_dir=$base/lib/rosdiff_crash_py
|
| 3 |
+
[install]
|
| 4 |
+
install_scripts=$base/lib/rosdiff_crash_py
|
worker/ros/samples/crash_py/rosdiff_crash_py/setup.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from glob import glob
|
| 2 |
+
|
| 3 |
+
from setuptools import setup
|
| 4 |
+
|
| 5 |
+
package_name = "rosdiff_crash_py"
|
| 6 |
+
|
| 7 |
+
setup(
|
| 8 |
+
name=package_name,
|
| 9 |
+
version="0.1.0",
|
| 10 |
+
packages=[package_name],
|
| 11 |
+
data_files=[
|
| 12 |
+
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
|
| 13 |
+
("share/" + package_name, ["package.xml"]),
|
| 14 |
+
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
|
| 15 |
+
],
|
| 16 |
+
install_requires=["setuptools"],
|
| 17 |
+
zip_safe=True,
|
| 18 |
+
maintainer="dev",
|
| 19 |
+
maintainer_email="dev@example.com",
|
| 20 |
+
description="Sample package",
|
| 21 |
+
license="Apache-2.0",
|
| 22 |
+
entry_points={"console_scripts": ["heartbeat = rosdiff_crash_py.heartbeat:main"]},
|
| 23 |
+
)
|
worker/ros/samples/good_cpp/rosdiff_sample_cpp/CMakeLists.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
cmake_minimum_required(VERSION 3.8)
|
| 2 |
+
project(rosdiff_sample_cpp)
|
| 3 |
+
|
| 4 |
+
find_package(ament_cmake REQUIRED)
|
| 5 |
+
find_package(rclcpp REQUIRED)
|
| 6 |
+
find_package(geometry_msgs REQUIRED)
|
| 7 |
+
find_package(moveit_msgs REQUIRED)
|
| 8 |
+
|
| 9 |
+
add_executable(twist_source src/twist_source.cpp)
|
| 10 |
+
ament_target_dependencies(twist_source rclcpp geometry_msgs moveit_msgs)
|
| 11 |
+
install(TARGETS twist_source DESTINATION lib/${PROJECT_NAME})
|
| 12 |
+
install(DIRECTORY launch DESTINATION share/${PROJECT_NAME})
|
| 13 |
+
|
| 14 |
+
if(BUILD_TESTING)
|
| 15 |
+
find_package(ament_lint_auto REQUIRED)
|
| 16 |
+
set(ament_cmake_copyright_FOUND TRUE)
|
| 17 |
+
set(ament_cmake_uncrustify_FOUND TRUE)
|
| 18 |
+
ament_lint_auto_find_test_dependencies()
|
| 19 |
+
endif()
|
| 20 |
+
|
| 21 |
+
ament_package()
|
worker/ros/samples/good_cpp/rosdiff_sample_cpp/launch/twist.launch.xml
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<launch>
|
| 2 |
+
<node pkg="rosdiff_sample_cpp" exec="twist_source" output="screen"/>
|
| 3 |
+
</launch>
|
worker/ros/samples/good_cpp/rosdiff_sample_cpp/package.xml
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0"?>
|
| 2 |
+
<package format="3">
|
| 3 |
+
<name>rosdiff_sample_cpp</name>
|
| 4 |
+
<version>0.1.0</version>
|
| 5 |
+
<description>Sample: C++ node using MoveIt 2 messages, with ament_lint_auto tests.</description>
|
| 6 |
+
<maintainer email="dev@example.com">dev</maintainer>
|
| 7 |
+
<license>Apache-2.0</license>
|
| 8 |
+
<buildtool_depend>ament_cmake</buildtool_depend>
|
| 9 |
+
<depend>rclcpp</depend>
|
| 10 |
+
<depend>geometry_msgs</depend>
|
| 11 |
+
<depend>moveit_msgs</depend>
|
| 12 |
+
<exec_depend>launch_ros</exec_depend>
|
| 13 |
+
<test_depend>ament_lint_auto</test_depend>
|
| 14 |
+
<test_depend>ament_lint_common</test_depend>
|
| 15 |
+
<export>
|
| 16 |
+
<build_type>ament_cmake</build_type>
|
| 17 |
+
</export>
|
| 18 |
+
</package>
|
worker/ros/samples/good_cpp/rosdiff_sample_cpp/src/twist_source.cpp
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
// Publishes a slow forward twist; including a MoveIt 2 message proves MoveIt is installed.
|
| 2 |
+
#include <chrono>
|
| 3 |
+
#include <memory>
|
| 4 |
+
|
| 5 |
+
#include "geometry_msgs/msg/twist.hpp"
|
| 6 |
+
#include "moveit_msgs/msg/robot_state.hpp"
|
| 7 |
+
#include "rclcpp/rclcpp.hpp"
|
| 8 |
+
|
| 9 |
+
using namespace std::chrono_literals;
|
| 10 |
+
|
| 11 |
+
class TwistSource : public rclcpp::Node
|
| 12 |
+
{
|
| 13 |
+
public:
|
| 14 |
+
TwistSource()
|
| 15 |
+
: Node("twist_source")
|
| 16 |
+
{
|
| 17 |
+
pub_ = create_publisher<geometry_msgs::msg::Twist>("cmd_vel", 10);
|
| 18 |
+
timer_ = create_wall_timer(100ms, [this]() {
|
| 19 |
+
geometry_msgs::msg::Twist msg;
|
| 20 |
+
msg.linear.x = 0.1;
|
| 21 |
+
pub_->publish(msg);
|
| 22 |
+
});
|
| 23 |
+
}
|
| 24 |
+
|
| 25 |
+
private:
|
| 26 |
+
rclcpp::Publisher<geometry_msgs::msg::Twist>::SharedPtr pub_;
|
| 27 |
+
rclcpp::TimerBase::SharedPtr timer_;
|
| 28 |
+
moveit_msgs::msg::RobotState unused_state_;
|
| 29 |
+
};
|
| 30 |
+
|
| 31 |
+
int main(int argc, char ** argv)
|
| 32 |
+
{
|
| 33 |
+
rclcpp::init(argc, argv);
|
| 34 |
+
rclcpp::spin(std::make_shared<TwistSource>());
|
| 35 |
+
rclcpp::shutdown();
|
| 36 |
+
return 0;
|
| 37 |
+
}
|
worker/ros/samples/good_py/rosdiff_sample_py/launch/heartbeat.launch.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from launch import LaunchDescription
|
| 2 |
+
from launch_ros.actions import Node
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
def generate_launch_description():
|
| 6 |
+
return LaunchDescription([Node(package="rosdiff_sample_py", executable="heartbeat", output="screen")])
|
worker/ros/samples/good_py/rosdiff_sample_py/package.xml
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<?xml version="1.0"?>
|
| 2 |
+
<package format="3">
|
| 3 |
+
<name>rosdiff_sample_py</name>
|
| 4 |
+
<version>0.1.0</version>
|
| 5 |
+
<description>Sample: publishes a heartbeat and checks that Nav2 and ros2_control messages are importable.</description>
|
| 6 |
+
<maintainer email="dev@example.com">dev</maintainer>
|
| 7 |
+
<license>Apache-2.0</license>
|
| 8 |
+
<depend>rclpy</depend>
|
| 9 |
+
<depend>std_msgs</depend>
|
| 10 |
+
<depend>nav2_msgs</depend>
|
| 11 |
+
<depend>control_msgs</depend>
|
| 12 |
+
<exec_depend>launch_ros</exec_depend>
|
| 13 |
+
<export>
|
| 14 |
+
<build_type>ament_python</build_type>
|
| 15 |
+
</export>
|
| 16 |
+
</package>
|
worker/ros/samples/good_py/rosdiff_sample_py/resource/rosdiff_sample_py
ADDED
|
File without changes
|
worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/__init__.py
ADDED
|
File without changes
|
worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/heartbeat.py
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Publishes a heartbeat; importing Nav2 and ros2_control messages proves they are installed."""
|
| 2 |
+
|
| 3 |
+
import rclpy
|
| 4 |
+
from control_msgs.msg import JointTrajectoryControllerState # noqa: F401
|
| 5 |
+
from nav2_msgs.action import NavigateToPose # noqa: F401
|
| 6 |
+
from rclpy.node import Node
|
| 7 |
+
from std_msgs.msg import String
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class Heartbeat(Node):
|
| 11 |
+
def __init__(self):
|
| 12 |
+
super().__init__("heartbeat")
|
| 13 |
+
self.pub = self.create_publisher(String, "heartbeat", 10)
|
| 14 |
+
self.count = 0
|
| 15 |
+
self.create_timer(0.2, self.tick)
|
| 16 |
+
|
| 17 |
+
def tick(self):
|
| 18 |
+
self.count += 1
|
| 19 |
+
self.pub.publish(String(data=f"beat {self.count}"))
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def main():
|
| 23 |
+
rclpy.init()
|
| 24 |
+
node = Heartbeat()
|
| 25 |
+
try:
|
| 26 |
+
rclpy.spin(node)
|
| 27 |
+
except KeyboardInterrupt:
|
| 28 |
+
pass
|
worker/ros/samples/good_py/rosdiff_sample_py/setup.cfg
ADDED
|
@@ -0,0 +1,4 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[develop]
|
| 2 |
+
script_dir=$base/lib/rosdiff_sample_py
|
| 3 |
+
[install]
|
| 4 |
+
install_scripts=$base/lib/rosdiff_sample_py
|
worker/ros/samples/good_py/rosdiff_sample_py/setup.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from glob import glob
|
| 2 |
+
|
| 3 |
+
from setuptools import setup
|
| 4 |
+
|
| 5 |
+
package_name = "rosdiff_sample_py"
|
| 6 |
+
|
| 7 |
+
setup(
|
| 8 |
+
name=package_name,
|
| 9 |
+
version="0.1.0",
|
| 10 |
+
packages=[package_name],
|
| 11 |
+
data_files=[
|
| 12 |
+
("share/ament_index/resource_index/packages", ["resource/" + package_name]),
|
| 13 |
+
("share/" + package_name, ["package.xml"]),
|
| 14 |
+
("share/" + package_name + "/launch", glob("launch/*.launch.py")),
|
| 15 |
+
],
|
| 16 |
+
install_requires=["setuptools"],
|
| 17 |
+
zip_safe=True,
|
| 18 |
+
maintainer="dev",
|
| 19 |
+
maintainer_email="dev@example.com",
|
| 20 |
+
description="Sample package",
|
| 21 |
+
license="Apache-2.0",
|
| 22 |
+
entry_points={"console_scripts": ["heartbeat = rosdiff_sample_py.heartbeat:main"]},
|
| 23 |
+
)
|
worker/ros_runner.py
ADDED
|
@@ -0,0 +1,414 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build, lint, test and launch a generated ROS 2 package, the way a developer would on their machine.
|
| 2 |
+
|
| 3 |
+
Runs inside the ROS 2 image (worker/ros/Dockerfile) as a sandbox: the Modal function has no network and no
|
| 4 |
+
secrets, and every ROS command gets a scrubbed environment. Standard library only, so it runs with the image's
|
| 5 |
+
own Python as well as Modal's.
|
| 6 |
+
|
| 7 |
+
python3 -m worker.ros_runner PACKAGE_DIR_OR_JSON --distro jazzy [--out OUT_DIR]
|
| 8 |
+
|
| 9 |
+
Steps (each one's output is kept as a log file):
|
| 10 |
+
1. colcon build build.log
|
| 11 |
+
2. ament linters on the sources lint.log (flake8/pep257 for Python, cpplint/cppcheck for C++, xmllint)
|
| 12 |
+
3. colcon test, if it has tests test.log (launch_testing, pytest, gtest, ament_lint_auto)
|
| 13 |
+
4. launch it for a few seconds launch.log (ros2 launch, or ros2 run for each executable without one)
|
| 14 |
+
while ros2 bag records every topic to MCAP bag.mcap
|
| 15 |
+
"""
|
| 16 |
+
|
| 17 |
+
from __future__ import annotations
|
| 18 |
+
|
| 19 |
+
import argparse
|
| 20 |
+
import json
|
| 21 |
+
import os
|
| 22 |
+
import random
|
| 23 |
+
import re
|
| 24 |
+
import shutil
|
| 25 |
+
import signal
|
| 26 |
+
import subprocess
|
| 27 |
+
import tempfile
|
| 28 |
+
import time
|
| 29 |
+
import xml.etree.ElementTree as ET
|
| 30 |
+
from pathlib import Path, PurePosixPath
|
| 31 |
+
|
| 32 |
+
DISTROS = ("humble", "jazzy")
|
| 33 |
+
BUILD_TIMEOUT = 600
|
| 34 |
+
TEST_TIMEOUT = 300
|
| 35 |
+
LINT_TIMEOUT = 120
|
| 36 |
+
LAUNCH_SECONDS = 10
|
| 37 |
+
MAX_BAG_BYTES = 20_000_000
|
| 38 |
+
MAX_LOG_BYTES = 400_000
|
| 39 |
+
LAUNCH_SUFFIXES = (".launch.py", "_launch.py", ".launch.xml", "_launch.xml", ".launch.yaml", "_launch.yaml")
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _env(distro: str, home: Path) -> dict[str, str]:
|
| 43 |
+
"""A clean environment: nothing from the host (no credentials), ROS traffic kept on localhost."""
|
| 44 |
+
env = {
|
| 45 |
+
"PATH": "/usr/local/sbin:/usr/sbin:/usr/bin:/sbin:/bin",
|
| 46 |
+
"HOME": str(home),
|
| 47 |
+
"LANG": "C.UTF-8",
|
| 48 |
+
"LC_ALL": "C.UTF-8",
|
| 49 |
+
"ROS_DISTRO": distro,
|
| 50 |
+
"ROS_DOMAIN_ID": str(random.randint(1, 100)),
|
| 51 |
+
"ROS_LOCALHOST_ONLY": "1", # humble
|
| 52 |
+
"ROS_AUTOMATIC_DISCOVERY_RANGE": "LOCALHOST", # jazzy
|
| 53 |
+
"PYTHONUNBUFFERED": "1",
|
| 54 |
+
"RCUTILS_COLORIZED_OUTPUT": "0",
|
| 55 |
+
"QT_QPA_PLATFORM": "offscreen",
|
| 56 |
+
"MAKEFLAGS": f"-j{os.cpu_count() or 2}",
|
| 57 |
+
}
|
| 58 |
+
return env
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _bash(script: str, ws: Path, env: dict, timeout: float) -> tuple[int | None, str]:
|
| 62 |
+
"""Run `script` in bash with ROS (and the workspace, once built) sourced. Returns (exit code or None, output)."""
|
| 63 |
+
distro = env["ROS_DISTRO"]
|
| 64 |
+
prelude = f"source /opt/ros/{distro}/setup.bash; [ -f install/setup.bash ] && source install/setup.bash; "
|
| 65 |
+
try:
|
| 66 |
+
p = subprocess.run(
|
| 67 |
+
["bash", "-c", prelude + script],
|
| 68 |
+
cwd=ws,
|
| 69 |
+
env=env,
|
| 70 |
+
capture_output=True,
|
| 71 |
+
text=True,
|
| 72 |
+
timeout=timeout,
|
| 73 |
+
errors="replace",
|
| 74 |
+
)
|
| 75 |
+
return p.returncode, p.stdout + p.stderr
|
| 76 |
+
except subprocess.TimeoutExpired as e:
|
| 77 |
+
out = (e.stdout or b"").decode(errors="replace") if isinstance(e.stdout, bytes) else (e.stdout or "")
|
| 78 |
+
return None, out + f"\n[rosdiff] timed out after {timeout:.0f}s\n"
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def write_workspace(files: dict[str, str], ws: Path) -> list[str]:
|
| 82 |
+
"""Write the generated files under ws/src. Returns the package names found."""
|
| 83 |
+
src = ws / "src"
|
| 84 |
+
src.mkdir(parents=True, exist_ok=True)
|
| 85 |
+
for rel, content in files.items():
|
| 86 |
+
p = PurePosixPath(rel)
|
| 87 |
+
if p.is_absolute() or ".." in p.parts:
|
| 88 |
+
raise ValueError(f"unsafe path in package: {rel}")
|
| 89 |
+
dest = src / p
|
| 90 |
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
| 91 |
+
dest.write_text(content)
|
| 92 |
+
names = []
|
| 93 |
+
for xml in sorted(src.rglob("package.xml")):
|
| 94 |
+
try:
|
| 95 |
+
name = ET.parse(xml).getroot().findtext("name")
|
| 96 |
+
except ET.ParseError:
|
| 97 |
+
name = None
|
| 98 |
+
if name:
|
| 99 |
+
names.append(name.strip())
|
| 100 |
+
return names
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
# --------------------------------------------------------------------------- steps
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
_MISSING = [
|
| 107 |
+
re.compile(r'provided by "([\w-]+)"'), # CMake find_package
|
| 108 |
+
re.compile(r"No module named '([\w.]+)'"), # Python import
|
| 109 |
+
re.compile(r"Package '([\w-]+)' not found"), # ros2 launch / ament index
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def missing_dependencies(text: str) -> list[str]:
|
| 114 |
+
found: list[str] = []
|
| 115 |
+
for pattern in _MISSING:
|
| 116 |
+
for name in pattern.findall(text):
|
| 117 |
+
if name not in found:
|
| 118 |
+
found.append(name)
|
| 119 |
+
return found
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def first_errors(text: str, limit: int = 8) -> list[str]:
|
| 123 |
+
"""The first compiler/Python/CMake error lines, for the summary."""
|
| 124 |
+
keys = ("error:", "Error:", "CMake Error", "Traceback", "SyntaxError", "ModuleNotFoundError", "ImportError")
|
| 125 |
+
lines = [ln.strip() for ln in text.splitlines() if any(k in ln for k in keys)]
|
| 126 |
+
out: list[str] = []
|
| 127 |
+
for ln in lines:
|
| 128 |
+
if ln not in out:
|
| 129 |
+
out.append(ln[:300])
|
| 130 |
+
if len(out) >= limit:
|
| 131 |
+
break
|
| 132 |
+
return out
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def build(ws: Path, env: dict) -> dict:
|
| 136 |
+
code, log = _bash(
|
| 137 |
+
"colcon build --event-handlers console_direct+ --cmake-args -DBUILD_TESTING=ON -DCMAKE_BUILD_TYPE=Release",
|
| 138 |
+
ws,
|
| 139 |
+
env,
|
| 140 |
+
BUILD_TIMEOUT,
|
| 141 |
+
)
|
| 142 |
+
(ws / "build.log").write_text(log)
|
| 143 |
+
return {
|
| 144 |
+
"ok": code == 0,
|
| 145 |
+
"exit_code": code,
|
| 146 |
+
"errors": first_errors(log) if code != 0 else [],
|
| 147 |
+
"missing_dependencies": missing_dependencies(log) if code != 0 else [],
|
| 148 |
+
}
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
def lint(ws: Path, env: dict, packages: dict[str, Path]) -> dict:
|
| 152 |
+
"""ament linters, as `ament_lint_auto` would run them. Reported, never a failure on their own."""
|
| 153 |
+
results: dict[str, int] = {}
|
| 154 |
+
logs = []
|
| 155 |
+
for name, pkg in packages.items():
|
| 156 |
+
py = any(pkg.rglob("*.py"))
|
| 157 |
+
cpp = any(p.suffix in (".cpp", ".hpp", ".cc", ".h") for p in pkg.rglob("*"))
|
| 158 |
+
tools = ["ament_xmllint"]
|
| 159 |
+
if py:
|
| 160 |
+
tools += ["ament_flake8", "ament_pep257"]
|
| 161 |
+
if cpp:
|
| 162 |
+
tools += ["ament_cpplint", "ament_cppcheck"]
|
| 163 |
+
for tool in tools:
|
| 164 |
+
code, out = _bash(f"{tool} {pkg}", ws, env, LINT_TIMEOUT)
|
| 165 |
+
issues = _count_issues(tool, out) if code else 0
|
| 166 |
+
results[tool] = results.get(tool, 0) + issues
|
| 167 |
+
logs.append(f"===== {name}: {tool} (exit {code})\n{out}")
|
| 168 |
+
(ws / "lint.log").write_text("\n".join(logs))
|
| 169 |
+
return {"issues": results, "total": sum(results.values())}
|
| 170 |
+
|
| 171 |
+
|
| 172 |
+
def _count_issues(tool: str, out: str) -> int:
|
| 173 |
+
m = re.search(r"(?:errors found: |^)(\d+)(?: (?:errors?|problems?))?\s*$", out, re.MULTILINE)
|
| 174 |
+
if m:
|
| 175 |
+
return int(m.group(1))
|
| 176 |
+
return max(1, sum(1 for ln in out.splitlines() if re.match(r"^\S+:\d+", ln)))
|
| 177 |
+
|
| 178 |
+
|
| 179 |
+
def test(ws: Path, env: dict) -> dict:
|
| 180 |
+
code, log = _bash(
|
| 181 |
+
"colcon test --event-handlers console_direct+ --return-code-on-test-failure; "
|
| 182 |
+
"echo '===== colcon test-result'; colcon test-result --all --verbose",
|
| 183 |
+
ws,
|
| 184 |
+
env,
|
| 185 |
+
TEST_TIMEOUT,
|
| 186 |
+
)
|
| 187 |
+
(ws / "test.log").write_text(log)
|
| 188 |
+
m = re.search(r"Summary: (\d+) tests?, (\d+) errors?, (\d+) failures?, (\d+) skipped", log)
|
| 189 |
+
if not m:
|
| 190 |
+
return {"ran": False, "ok": code == 0}
|
| 191 |
+
total, errors, failures, skipped = map(int, m.groups())
|
| 192 |
+
return {
|
| 193 |
+
"ran": total > 0,
|
| 194 |
+
"ok": errors == 0 and failures == 0,
|
| 195 |
+
"tests": total,
|
| 196 |
+
"errors": errors,
|
| 197 |
+
"failures": failures,
|
| 198 |
+
"skipped": skipped,
|
| 199 |
+
}
|
| 200 |
+
|
| 201 |
+
|
| 202 |
+
def _launch_targets(ws: Path, env: dict, packages: dict[str, Path]) -> list[tuple[str, list[str]]]:
|
| 203 |
+
"""What to start: each package's launch file, else each of its executables (at most 3 per package)."""
|
| 204 |
+
targets = []
|
| 205 |
+
for name, pkg in packages.items():
|
| 206 |
+
launches = sorted(
|
| 207 |
+
p for p in pkg.rglob("*") if p.is_file() and p.name.endswith(LAUNCH_SUFFIXES) and "test" not in p.parts
|
| 208 |
+
)
|
| 209 |
+
if launches:
|
| 210 |
+
targets.append((f"ros2 launch {name} {launches[0].name}", [name, launches[0].name]))
|
| 211 |
+
continue
|
| 212 |
+
_, out = _bash(f"ros2 pkg executables {name}", ws, env, 60)
|
| 213 |
+
for line in out.splitlines()[:3]:
|
| 214 |
+
parts = line.split()
|
| 215 |
+
if len(parts) == 2 and parts[0] == name:
|
| 216 |
+
targets.append((f"ros2 run {name} {parts[1]}", parts))
|
| 217 |
+
return targets
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _start(cmd: str, ws: Path, env: dict, log: Path) -> subprocess.Popen:
|
| 221 |
+
distro = env["ROS_DISTRO"]
|
| 222 |
+
script = f"source /opt/ros/{distro}/setup.bash; source install/setup.bash; exec {cmd}"
|
| 223 |
+
return subprocess.Popen(
|
| 224 |
+
["bash", "-c", script],
|
| 225 |
+
cwd=ws,
|
| 226 |
+
env=env,
|
| 227 |
+
stdout=log.open("w"),
|
| 228 |
+
stderr=subprocess.STDOUT,
|
| 229 |
+
start_new_session=True, # its own process group, so every node it spawns can be stopped
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
|
| 233 |
+
def _stop(proc: subprocess.Popen, grace: float = 8.0) -> int | None:
|
| 234 |
+
if proc.poll() is not None:
|
| 235 |
+
return proc.returncode
|
| 236 |
+
for sig, wait in ((signal.SIGINT, grace), (signal.SIGTERM, 3.0), (signal.SIGKILL, 2.0)):
|
| 237 |
+
try:
|
| 238 |
+
os.killpg(proc.pid, sig)
|
| 239 |
+
except ProcessLookupError:
|
| 240 |
+
break
|
| 241 |
+
try:
|
| 242 |
+
proc.wait(wait)
|
| 243 |
+
break
|
| 244 |
+
except subprocess.TimeoutExpired:
|
| 245 |
+
continue
|
| 246 |
+
return None # still running when we stopped it: that is what we want
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def launch(ws: Path, env: dict, packages: dict[str, Path], seconds: float = LAUNCH_SECONDS) -> dict:
|
| 250 |
+
targets = _launch_targets(ws, env, packages)
|
| 251 |
+
if not targets:
|
| 252 |
+
(ws / "launch.log").write_text("nothing to launch: no launch file and no executables\n")
|
| 253 |
+
return {"ok": False, "started": [], "error": "no launch file and no executables to start"}
|
| 254 |
+
|
| 255 |
+
logs_dir = ws / "launch_logs"
|
| 256 |
+
logs_dir.mkdir()
|
| 257 |
+
bag = subprocess.Popen(
|
| 258 |
+
[
|
| 259 |
+
"bash",
|
| 260 |
+
"-c",
|
| 261 |
+
f"source /opt/ros/{env['ROS_DISTRO']}/setup.bash; exec ros2 bag record -a -s mcap -o {ws / 'bag'}",
|
| 262 |
+
],
|
| 263 |
+
cwd=ws,
|
| 264 |
+
env=env,
|
| 265 |
+
stdout=(logs_dir / "bag.log").open("w"),
|
| 266 |
+
stderr=subprocess.STDOUT,
|
| 267 |
+
start_new_session=True,
|
| 268 |
+
)
|
| 269 |
+
time.sleep(2.0) # let the recorder subscribe before the nodes start publishing
|
| 270 |
+
procs = [(cmd, _start(cmd, ws, env, logs_dir / f"{i}.log")) for i, (cmd, _) in enumerate(targets)]
|
| 271 |
+
exited_early: list[tuple[str, int]] = []
|
| 272 |
+
deadline = time.monotonic() + seconds
|
| 273 |
+
graph: dict = {}
|
| 274 |
+
while time.monotonic() < deadline:
|
| 275 |
+
time.sleep(0.5)
|
| 276 |
+
for cmd, p in procs:
|
| 277 |
+
if p.poll() is not None and (cmd, p.returncode) not in exited_early:
|
| 278 |
+
exited_early.append((cmd, p.returncode))
|
| 279 |
+
if not graph and time.monotonic() > deadline - seconds / 2:
|
| 280 |
+
graph = _graph(ws, env)
|
| 281 |
+
if not graph:
|
| 282 |
+
graph = _graph(ws, env)
|
| 283 |
+
for _, p in procs:
|
| 284 |
+
_stop(p)
|
| 285 |
+
_stop(bag, grace=10.0)
|
| 286 |
+
|
| 287 |
+
texts = []
|
| 288 |
+
for i, (cmd, _) in enumerate(procs):
|
| 289 |
+
texts.append(f"===== {cmd}\n" + (logs_dir / f"{i}.log").read_text(errors="replace"))
|
| 290 |
+
texts.append("===== ros2 bag record\n" + (logs_dir / "bag.log").read_text(errors="replace"))
|
| 291 |
+
log = "\n".join(texts)
|
| 292 |
+
(ws / "launch.log").write_text(log)
|
| 293 |
+
|
| 294 |
+
died = re.findall(r"\[ERROR\] \[([^\]]+)\]: process has died \[pid \d+, exit code (-?\d+)", log)
|
| 295 |
+
crashed = [f"{name} (exit {code})" for name, code in died]
|
| 296 |
+
crashed += [f"{cmd} (exit {code})" for cmd, code in exited_early if code != 0]
|
| 297 |
+
tracebacks = first_errors(log)
|
| 298 |
+
ok = not crashed and bool(graph.get("nodes"))
|
| 299 |
+
return {
|
| 300 |
+
"ok": ok,
|
| 301 |
+
"started": [cmd for cmd, _ in procs],
|
| 302 |
+
"seconds": seconds,
|
| 303 |
+
"nodes": graph.get("nodes", []),
|
| 304 |
+
"topics": graph.get("topics", []),
|
| 305 |
+
"crashed": crashed,
|
| 306 |
+
"errors": tracebacks,
|
| 307 |
+
"missing_dependencies": missing_dependencies(log),
|
| 308 |
+
"error": None if ok else ("a node crashed" if crashed else "no ROS node came up"),
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
def _graph(ws: Path, env: dict) -> dict:
|
| 313 |
+
_, nodes = _bash("ros2 node list --no-daemon --spin-time 2", ws, env, 30)
|
| 314 |
+
_, topics = _bash("ros2 topic list -t --no-daemon --spin-time 2", ws, env, 30)
|
| 315 |
+
hidden = ("/rosbag2", "/_ros2cli", "/launch_ros")
|
| 316 |
+
node_list = [n.strip() for n in nodes.splitlines() if n.startswith("/") and not n.strip().startswith(hidden)]
|
| 317 |
+
topic_list = [t.strip() for t in topics.splitlines() if t.startswith("/")]
|
| 318 |
+
return {"nodes": node_list, "topics": topic_list}
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
# --------------------------------------------------------------------------- job
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def check(files: dict[str, str], distro: str, out: Path, launch_seconds: float = LAUNCH_SECONDS) -> dict:
|
| 325 |
+
if distro not in DISTROS:
|
| 326 |
+
raise ValueError(f"distro must be one of {DISTROS}")
|
| 327 |
+
started = time.monotonic()
|
| 328 |
+
root = Path(tempfile.mkdtemp(prefix="ros_check_"))
|
| 329 |
+
ws, home = root / "ws", root / "home"
|
| 330 |
+
home.mkdir(parents=True)
|
| 331 |
+
ws.mkdir()
|
| 332 |
+
env = _env(distro, home)
|
| 333 |
+
try:
|
| 334 |
+
names = write_workspace(files, ws)
|
| 335 |
+
if not names:
|
| 336 |
+
raise ValueError("no package.xml with a <name> in the generated files")
|
| 337 |
+
packages = {}
|
| 338 |
+
for xml in (ws / "src").rglob("package.xml"):
|
| 339 |
+
n = ET.parse(xml).getroot().findtext("name")
|
| 340 |
+
if n:
|
| 341 |
+
packages[n.strip()] = xml.parent
|
| 342 |
+
result: dict = {"distro": distro, "packages": names}
|
| 343 |
+
result["build"] = build(ws, env)
|
| 344 |
+
result["lint"] = lint(ws, env, packages)
|
| 345 |
+
if result["build"]["ok"]:
|
| 346 |
+
result["test"] = test(ws, env)
|
| 347 |
+
result["launch"] = launch(ws, env, packages, launch_seconds)
|
| 348 |
+
result["headline"] = headline(result)
|
| 349 |
+
result["passed"] = bool(result["build"]["ok"] and result.get("launch", {}).get("ok"))
|
| 350 |
+
|
| 351 |
+
out.mkdir(parents=True, exist_ok=True)
|
| 352 |
+
for name in ("build.log", "lint.log", "test.log", "launch.log"):
|
| 353 |
+
if (ws / name).is_file():
|
| 354 |
+
data = (ws / name).read_bytes()
|
| 355 |
+
(out / name).write_bytes(data[-MAX_LOG_BYTES:])
|
| 356 |
+
bags = sorted((ws / "bag").glob("*.mcap")) if (ws / "bag").is_dir() else []
|
| 357 |
+
if bags and bags[0].stat().st_size <= MAX_BAG_BYTES:
|
| 358 |
+
shutil.copy(bags[0], out / "bag.mcap")
|
| 359 |
+
result["bag_bytes"] = bags[0].stat().st_size
|
| 360 |
+
result["worker_seconds"] = round(time.monotonic() - started, 1)
|
| 361 |
+
(out / "summary.json").write_text(json.dumps(result, indent=2))
|
| 362 |
+
return result
|
| 363 |
+
finally:
|
| 364 |
+
shutil.rmtree(root, ignore_errors=True)
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def headline(r: dict) -> str:
|
| 368 |
+
if not r["build"]["ok"]:
|
| 369 |
+
missing = r["build"].get("missing_dependencies")
|
| 370 |
+
return "does not build" + (f" (missing: {', '.join(missing[:4])})" if missing else "")
|
| 371 |
+
parts = ["builds"]
|
| 372 |
+
la = r.get("launch") or {}
|
| 373 |
+
if la.get("ok"):
|
| 374 |
+
parts.append(f"{len(la['nodes'])} node(s) up, {len(la['topics'])} topic(s)")
|
| 375 |
+
else:
|
| 376 |
+
parts.append(f"launch failed: {la.get('error')}")
|
| 377 |
+
t = r.get("test") or {}
|
| 378 |
+
if t.get("ran"):
|
| 379 |
+
parts.append(f"tests {t['tests'] - t['failures'] - t['errors']}/{t['tests']} passed")
|
| 380 |
+
lint_total = (r.get("lint") or {}).get("total", 0)
|
| 381 |
+
parts.append(f"lint: {lint_total} issue(s)" if lint_total else "lint clean")
|
| 382 |
+
return " · ".join(parts)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def run_check_job(job_input: dict, distro: str) -> dict:
|
| 386 |
+
"""Worker entry point: returns the summary plus the logs/bag as inline artifacts (the API stores them)."""
|
| 387 |
+
files = job_input["files"]
|
| 388 |
+
if not isinstance(files, dict) or not files:
|
| 389 |
+
raise ValueError("files must be a non-empty {path: content} mapping")
|
| 390 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 391 |
+
out = Path(tmp)
|
| 392 |
+
summary = check(files, distro, out)
|
| 393 |
+
artifacts = {p.name: p.read_bytes() for p in sorted(out.iterdir()) if p.is_file()}
|
| 394 |
+
return {"summary": summary, "artifacts": artifacts, "worker_seconds": summary["worker_seconds"]}
|
| 395 |
+
|
| 396 |
+
|
| 397 |
+
def main(argv: list[str] | None = None) -> int:
|
| 398 |
+
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
|
| 399 |
+
parser.add_argument("source", type=Path, help="a directory containing package(s), or a JSON {path: content} file")
|
| 400 |
+
parser.add_argument("--distro", default=os.environ.get("ROS_DISTRO", "jazzy"), choices=DISTROS)
|
| 401 |
+
parser.add_argument("--out", type=Path, default=Path("ros_check_out"))
|
| 402 |
+
parser.add_argument("--launch-seconds", type=float, default=LAUNCH_SECONDS)
|
| 403 |
+
args = parser.parse_args(argv)
|
| 404 |
+
if args.source.is_dir():
|
| 405 |
+
files = {p.relative_to(args.source).as_posix(): p.read_text() for p in args.source.rglob("*") if p.is_file()}
|
| 406 |
+
else:
|
| 407 |
+
files = json.loads(args.source.read_text())
|
| 408 |
+
result = check(files, args.distro, args.out, args.launch_seconds)
|
| 409 |
+
print(json.dumps(result, indent=2))
|
| 410 |
+
return 0 if result["passed"] else 1
|
| 411 |
+
|
| 412 |
+
|
| 413 |
+
if __name__ == "__main__":
|
| 414 |
+
raise SystemExit(main())
|