Chandra Kiran commited on
Commit
dbf4bd1
·
unverified ·
1 Parent(s): 1a36c6b

Add a ROS 2 sandbox that builds, lints, tests and launches packages

Browse files

worker/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.

Files changed (25) hide show
  1. .github/workflows/ci.yml +53 -0
  2. worker/modal_app.py +46 -0
  3. worker/ros/Dockerfile +42 -0
  4. worker/ros/samples/broken_cpp/rosdiff_broken_cpp/CMakeLists.txt +9 -0
  5. worker/ros/samples/broken_cpp/rosdiff_broken_cpp/package.xml +14 -0
  6. worker/ros/samples/broken_cpp/rosdiff_broken_cpp/src/broken.cpp +11 -0
  7. worker/ros/samples/crash_py/rosdiff_crash_py/launch/heartbeat.launch.py +6 -0
  8. worker/ros/samples/crash_py/rosdiff_crash_py/package.xml +14 -0
  9. worker/ros/samples/crash_py/rosdiff_crash_py/resource/rosdiff_crash_py +0 -0
  10. worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/__init__.py +0 -0
  11. worker/ros/samples/crash_py/rosdiff_crash_py/rosdiff_crash_py/heartbeat.py +11 -0
  12. worker/ros/samples/crash_py/rosdiff_crash_py/setup.cfg +4 -0
  13. worker/ros/samples/crash_py/rosdiff_crash_py/setup.py +23 -0
  14. worker/ros/samples/good_cpp/rosdiff_sample_cpp/CMakeLists.txt +21 -0
  15. worker/ros/samples/good_cpp/rosdiff_sample_cpp/launch/twist.launch.xml +3 -0
  16. worker/ros/samples/good_cpp/rosdiff_sample_cpp/package.xml +18 -0
  17. worker/ros/samples/good_cpp/rosdiff_sample_cpp/src/twist_source.cpp +37 -0
  18. worker/ros/samples/good_py/rosdiff_sample_py/launch/heartbeat.launch.py +6 -0
  19. worker/ros/samples/good_py/rosdiff_sample_py/package.xml +16 -0
  20. worker/ros/samples/good_py/rosdiff_sample_py/resource/rosdiff_sample_py +0 -0
  21. worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/__init__.py +0 -0
  22. worker/ros/samples/good_py/rosdiff_sample_py/rosdiff_sample_py/heartbeat.py +28 -0
  23. worker/ros/samples/good_py/rosdiff_sample_py/setup.cfg +4 -0
  24. worker/ros/samples/good_py/rosdiff_sample_py/setup.py +23 -0
  25. 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())