diff --git a/.github/workflows/bazelized.yml b/.github/workflows/bazelized.yml new file mode 100644 index 000000000..71b2c55bc --- /dev/null +++ b/.github/workflows/bazelized.yml @@ -0,0 +1,29 @@ +name: Bazel ROS Continuous Integration + +on: + pull_request: + branches: + - main + - develop + +defaults: + run: + working-directory: drake_ros_bazel_installed + +jobs: + build_and_test: + runs-on: ubuntu-latest + container: + image: ros:rolling-ros-base-focal + steps: + - uses: actions/checkout@v2 + - uses: actions/cache@v2 + with: + path: "/home/runner/.cache/bazel" + key: bazel + - name: Install prerequisites + run: yes | ./setup/install_prereqs.sh + - name: Build Bazel workspace + run: bazel build //... + - name: Test Bazel workspace + run: bazel test //... @ros2//... diff --git a/drake_ros_bazel_installed/.bazelrc b/drake_ros_bazel_installed/.bazelrc new file mode 100644 index 000000000..a7d45b413 --- /dev/null +++ b/drake_ros_bazel_installed/.bazelrc @@ -0,0 +1,9 @@ +# Perform optimized builds. +build -c opt + +# Use C++17. +build --cxxopt=-std=c++17 +build --host_cxxopt=-std=c++17 + +# Use Python 3. +build --python_path=/usr/bin/python3 diff --git a/drake_ros_bazel_installed/BUILD.bazel b/drake_ros_bazel_installed/BUILD.bazel new file mode 100644 index 000000000..868c8a80d --- /dev/null +++ b/drake_ros_bazel_installed/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package root. diff --git a/drake_ros_bazel_installed/README.md b/drake_ros_bazel_installed/README.md new file mode 100644 index 000000000..741f47424 --- /dev/null +++ b/drake_ros_bazel_installed/README.md @@ -0,0 +1,33 @@ +# Bazel Project with ROS 2 as a Precompiled External + +This project pulls in a system installed ROS 2 distribution as a Bazel external repository. + +For an introduction to Bazel, refer to [Getting Started with Bazel](https://docs.bazel.build/versions/master/getting-started.html). + +## Platform support + +This project targets ROS 2 Rolling distributions on Ubuntu Focal 20.04 only. + +## Instructions + +First, install the required dependencies: + +```sh +sudo ./setup/install_prereqs.sh +``` + +To build all packages: + +```sh +bazel build //... +``` + +To run binaries directly: + +```sh +bazel run //drake_ros_apps:oracle_cc +``` + +```sh +bazel run //drake_ros_apps:inquirer_py +``` diff --git a/drake_ros_bazel_installed/WORKSPACE b/drake_ros_bazel_installed/WORKSPACE new file mode 100644 index 000000000..2ef502c46 --- /dev/null +++ b/drake_ros_bazel_installed/WORKSPACE @@ -0,0 +1,9 @@ +workspace(name = "drake_ros") + +load("//tools/workspace/python:repository.bzl", "python_repository") + +python_repository(name = "python_dev") + +load("//tools/workspace/ros2:repository.bzl", "ros2_repository") + +ros2_repository(name = "ros2") diff --git a/drake_ros_bazel_installed/drake_ros_apps/BUILD.bazel b/drake_ros_bazel_installed/drake_ros_apps/BUILD.bazel new file mode 100644 index 000000000..05b299264 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/BUILD.bazel @@ -0,0 +1,93 @@ +# -*- mode: python -*- +# vi: set ft=python : + +load("@ros2//:ros_cc.bzl", "ros_cc_binary") +load("@ros2//:ros_py.bzl", "ros_py_binary") +load("@ros2//:rosidl.bzl", "rosidl_interfaces_group") + +rosidl_interfaces_group( + name = "drake_ros_apps_msgs", + interfaces = [ + "msg/Status.msg", + ], + deps = [ + "//drake_ros_common:drake_ros_common_msgs", + "@ros2//:builtin_interfaces", + ], +) + +ros_cc_binary( + name = "oracle_cc", + srcs = ["oracle.cc"], + deps = [ + ":drake_ros_apps_msgs_cc", + "//drake_ros_common:drake_ros_common_msgs_cc", + "@ros2//:rclcpp_cc", + "@ros2//:rclcpp_action_cc", + ], + rmw_implementation = "rmw_cyclonedds_cpp", + tags = ["requires-network"], +) + +ros_cc_binary( + name = "inquirer_cc", + srcs = ["inquirer.cc"], + deps = [ + ":drake_ros_apps_msgs_cc", + "//drake_ros_common:drake_ros_common_msgs_cc", + "@ros2//:rclcpp_cc", + "@ros2//:rclcpp_action_cc", + ], + rmw_implementation = "rmw_cyclonedds_cpp", + tags = ["requires-network"], +) + +ros_py_binary( + name = "oracle_py", + srcs = ["oracle.py"], + main = "oracle.py", + deps = [ + ":drake_ros_apps_msgs_py", + "//drake_ros_common:drake_ros_common_msgs_py", + "@ros2//:rclpy_py", + ], + rmw_implementation = "rmw_cyclonedds_cpp", + tags = ["requires-network"], +) + +ros_py_binary( + name = "inquirer_py", + srcs = ["inquirer.py"], + main = "inquirer.py", + deps = [ + ":drake_ros_apps_msgs_py", + "//drake_ros_common:drake_ros_common_msgs_py", + "@ros2//:rclpy_py", + ], + rmw_implementation = "rmw_cyclonedds_cpp", + tags = ["requires-network"], +) + +sh_test( + name = "gdb_oracle_cc_test", + srcs = ["test/exec.sh"], + args = [ + "gdb", + "-x", "$(location :test/oracle_cc.gdb)", "--batch", "--args", + "$(location :oracle_cc)", "--ros-args", "--disable-external-lib-logs", + ], + data = ["test/oracle_cc.gdb", ":oracle_cc"], + size = "small", +) + +sh_test( + name = "lldb_oracle_cc_test", + srcs = ["test/exec.sh"], + args = [ + "lldb", + "-s", "$(location :test/oracle_cc.lldb)", "--batch", "--", + "$(location :oracle_cc)", "--ros-args", "--disable-external-lib-logs", + ], + data = ["test/oracle_cc.lldb", ":oracle_cc"], + size = "small", +) diff --git a/drake_ros_bazel_installed/drake_ros_apps/README.md b/drake_ros_bazel_installed/drake_ros_apps/README.md new file mode 100644 index 000000000..bfd33f3ba --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/README.md @@ -0,0 +1,18 @@ +## Demo `drake_ros` apps + +This package exercises `rosidl` interface generation and usage in C++ and Python, with cross-package interface dependencies. + +To check that the resulting interfaces are functional, it includes a demo applications in C++ and Python. Namely: + +- Equivalent `oracle_(cc|py)` applications that publish `drake_ros_apps_msgs/msg/Status` messages, serve a `drake_ros_common_msgs/srv/Query`service, and provide a `drake_ros_common_msgs/action/Do` action. +- Equivalent `inquirer_(cc|py)` applications that subscribe to `drake_ros_apps_msgs/msg/Status` messages, make `drake_ros_common_msgs/srv/Query` service requests, and invoke the `drake_ros_common_msgs/action/Do` action. + +You may run a C++ oracle against a Python inquirer, and vice versa: + +```sh +bazel run //drake_ros_apps:oracle_py +``` + +```sh +bazel run //drake_ros_apps:inquirer_cc +``` diff --git a/drake_ros_bazel_installed/drake_ros_apps/inquirer.cc b/drake_ros_bazel_installed/drake_ros_apps/inquirer.cc new file mode 100644 index 000000000..04e1255b0 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/inquirer.cc @@ -0,0 +1,133 @@ +#include +#include + +#include +#include +#include + +#include "drake_ros_apps_msgs/msg/status.hpp" +#include "drake_ros_common_msgs/action/do.hpp" +#include "drake_ros_common_msgs/srv/query.hpp" + +using namespace std::chrono_literals; + +namespace drake_ros_apps { + +class Inquirer : public rclcpp::Node { + public: + Inquirer() : Node("inquirer") { + using namespace std::placeholders; + status_sub_ = this->create_subscription( + "status", rclcpp::QoS(rclcpp::KeepLast(1)), + std::bind(&Inquirer::on_status, this, _1)); + + query_client_ = + this->create_client("query"); + + action_client_ = + rclcpp_action::create_client(this, + "do"); + + inquire_timer_ = + this->create_wall_timer(5s, std::bind(&Inquirer::inquire, this)); + } + + private: + using QueryClient = rclcpp::Client; + + void handle_reply(QueryClient::SharedFuture future) const { + RCLCPP_INFO(this->get_logger(), "oracle said: %s", + future.get()->reply.c_str()); + } + + using ActionGoalHandle = + rclcpp_action::ClientGoalHandle; + void handle_rite_request_response( + const std::shared_ptr handle) const { + if (!handle) { + RCLCPP_ERROR(this->get_logger(), "oracle rejected rite request"); + } else { + RCLCPP_INFO(this->get_logger(), "oracle rite in progress"); + } + } + + void handle_rite_feedback( + std::shared_ptr handle, + const std::shared_ptr feedback) const { + RCLCPP_INFO(this->get_logger(), "oracle is %s", feedback->message.c_str()); + } + + void handle_rite_result(const ActionGoalHandle::WrappedResult& result) const { + switch (result.code) { + case rclcpp_action::ResultCode::SUCCEEDED: + RCLCPP_INFO(this->get_logger(), "oracle rite is complete"); + break; + case rclcpp_action::ResultCode::ABORTED: + RCLCPP_ERROR(this->get_logger(), "oracle rite aborted due to %s", + result.result->reason.c_str()); + break; + case rclcpp_action::ResultCode::CANCELED: + RCLCPP_WARN(this->get_logger(), "oracle rite was cancelled"); + break; + default: + RCLCPP_ERROR(this->get_logger(), "oracle rite status unknown"); + break; + } + } + + void inquire() const { + using namespace std::placeholders; + + if (query_client_->service_is_ready()) { + auto request = + std::make_shared(); + request->query = "how's it going?"; + RCLCPP_INFO(this->get_logger(), "oracle, %s", request->query.c_str()); + query_client_->async_send_request( + request, std::bind(&Inquirer::handle_reply, this, _1)); + } else { + RCLCPP_WARN(this->get_logger(), "oracle not available for queries"); + } + + if (action_client_->action_server_is_ready()) { + rclcpp_action::Client::SendGoalOptions + options; + options.goal_response_callback = + std::bind(&Inquirer::handle_rite_request_response, this, _1); + options.feedback_callback = + std::bind(&Inquirer::handle_rite_feedback, this, _1, _2); + options.result_callback = + std::bind(&Inquirer::handle_rite_result, this, _1); + drake_ros_common_msgs::action::Do::Goal goal; + goal.action = "rite"; + goal.period = rclcpp::Duration::from_seconds(0.1); + goal.timeout = rclcpp::Duration::from_seconds(1.0); + action_client_->async_send_goal(goal, options); + } else { + RCLCPP_WARN(this->get_logger(), "oracle not available for actions"); + } + } + + void on_status(const drake_ros_apps_msgs::msg::Status& msg) const { + RCLCPP_INFO(get_logger(), "%s status (%lu): %s", msg.origin.c_str(), + msg.status.sequence_id, msg.status.message.c_str()); + } + + std::shared_ptr> + status_sub_; + std::shared_ptr> + query_client_; + std::shared_ptr> + action_client_; + std::shared_ptr inquire_timer_; +}; + +} // namespace drake_ros_apps + +int main(int argc, char** argv) { + rclcpp::init(argc, argv); + + rclcpp::spin(std::make_shared()); + + rclcpp::shutdown(); +} diff --git a/drake_ros_bazel_installed/drake_ros_apps/inquirer.py b/drake_ros_bazel_installed/drake_ros_apps/inquirer.py new file mode 100644 index 000000000..6061fc559 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/inquirer.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 + +import action_msgs.msg +import rclpy +import rclpy.action +import rclpy.duration +import rclpy.node +import rclpy.qos + +import drake_ros_apps_msgs.msg +import drake_ros_common_msgs.action +import drake_ros_common_msgs.srv + + +class Inquirer(rclpy.node.Node): + + def __init__(self): + super().__init__('inquirer') + self._status_sub = self.create_subscription( + drake_ros_apps_msgs.msg.Status, 'status', self._on_status, + rclpy.qos.QoSProfile(depth=1)) + self._query_client = self.create_client( + drake_ros_common_msgs.srv.Query, 'query') + self._action_client = rclpy.action.ActionClient( + self, drake_ros_common_msgs.action.Do, 'do') + self._inquire_timer = self.create_timer(5.0, self.inquire) + + def _on_status(self, msg): + self.get_logger().info('{} status ({}): {}'.format( + msg.origin, msg.status.sequence_id, msg.status.message + )) + + def _handle_reply(self, future): + self.get_logger().info('oracle said: ' + future.result().reply) + + def _handle_rite_request_response(self, future): + handle = future.result() + if not handle.accepted: + self.get_logger().error('oracle rejected rite request') + return + self.get_logger().info('oracle rite in progress') + future = handle.get_result_async() + future.add_done_callback(self._handle_rite_result) + + def _handle_rite_feedback(self, msg): + self.get_logger().info('oracle is ' + msg.feedback.message) + + def _handle_rite_result(self, future): + result = future.result() + if result.status == action_msgs.msg.GoalStatus.STATUS_SUCCEEDED: + self.get_logger().info('oracle rite is complete') + elif result.status == action_msgs.msg.GoalStatus.STATUS_ABORTED: + self.get_logger().error( + 'oracle rite aborted due to ' + result.result.reason + ) + elif result.status == action_msgs.msg.GoalStatus.STATUS_CANCELED: + self.get_logger().error('oracle rite was cancelled') + else: + self.get_logger().error('oracle rite status unknown') + + def inquire(self): + if self._query_client.service_is_ready(): + request = drake_ros_common_msgs.srv.Query.Request() + request.query = "how's it going?" + self.get_logger().info('oracle, ' + request.query) + future = self._query_client.call_async(request) + future.add_done_callback(self._handle_reply) + else: + self.get_logger().warning('oracle not available for queries') + if self._action_client.server_is_ready(): + goal = drake_ros_common_msgs.action.Do.Goal() + goal.action = 'rite' + goal.period = rclpy.duration.Duration(seconds=0.1).to_msg() + goal.timeout = rclpy.duration.Duration(seconds=1.0).to_msg() + future = self._action_client.send_goal_async( + goal, feedback_callback=self._handle_rite_feedback) + future.add_done_callback(self._handle_rite_request_response) + else: + self.get_logger().warning('oracle not available for actions') + + +def main(): + rclpy.init() + + try: + rclpy.spin(Inquirer()) + except KeyboardInterrupt: + pass + finally: + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/drake_ros_apps/msg/Status.msg b/drake_ros_bazel_installed/drake_ros_apps/msg/Status.msg new file mode 100644 index 000000000..111599f39 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/msg/Status.msg @@ -0,0 +1,3 @@ +builtin_interfaces/Time stamp +drake_ros_common_msgs/Status status +string origin diff --git a/drake_ros_bazel_installed/drake_ros_apps/oracle.cc b/drake_ros_bazel_installed/drake_ros_apps/oracle.cc new file mode 100644 index 000000000..a87b17d01 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/oracle.cc @@ -0,0 +1,146 @@ +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "drake_ros_apps_msgs/msg/status.hpp" +#include "drake_ros_common_msgs/action/do.hpp" +#include "drake_ros_common_msgs/srv/query.hpp" + +using namespace std::chrono_literals; + +namespace drake_ros_apps { + +class Oracle : public rclcpp::Node { + public: + Oracle() : Node("oracle") { + using namespace std::placeholders; + + status_pub_ = this->create_publisher( + "status", rclcpp::QoS(rclcpp::KeepLast(1))); + + query_server_ = this->create_service( + "query", std::bind(&Oracle::handle_query, this, _1, _2)); + + action_server_ = + rclcpp_action::create_server( + this, "do", std::bind(&Oracle::handle_action_goal, this, _1, _2), + std::bind(&Oracle::handle_cancelled_action, this, _1), + std::bind(&Oracle::handle_accepted_action, this, _1)); + + status_timer_ = + this->create_wall_timer(1s, std::bind(&Oracle::publish_status, this)); + } + + private: + rclcpp_action::GoalResponse handle_action_goal( + const rclcpp_action::GoalUUID&, + const std::shared_ptr + goal) { + if (goal->action != "rite") { + RCLCPP_WARN(this->get_logger(), "Don't know how to %s", + goal->action.c_str()); + return rclcpp_action::GoalResponse::REJECT; + } + return rclcpp_action::GoalResponse::ACCEPT_AND_EXECUTE; + } + + using GoalHandle = + rclcpp_action::ServerGoalHandle; + + rclcpp_action::CancelResponse handle_cancelled_action( + const std::shared_ptr) { + return rclcpp_action::CancelResponse::ACCEPT; + } + + void handle_accepted_action(const std::shared_ptr handle) { + action_start_time_ = this->get_clock()->now(); + action_loop_ = this->create_wall_timer( + rclcpp::Duration{handle->get_goal()->period} + .to_chrono(), + [this, handle]() { this->handle_rite_action(handle); }); + } + + void handle_rite_action(const std::shared_ptr handle) { + if (handle->is_canceling()) { + auto result = + std::make_shared(); + handle->canceled(result); + action_loop_->cancel(); + return; + } + + auto current_time = this->get_clock()->now(); + if (current_time - action_start_time_ > handle->get_goal()->timeout) { + auto result = + std::make_shared(); + result->reason = "timeout"; + handle->abort(result); + action_loop_->cancel(); + return; + } + + if (is_rite_complete_(gen_)) { + auto result = + std::make_shared(); + handle->succeed(result); + action_loop_->cancel(); + return; + } + + auto feedback = + std::make_shared(); + feedback->message = "chanting"; + handle->publish_feedback(feedback); + } + + void handle_query( + const std::shared_ptr request, + std::shared_ptr response) + const { + if (request->query == "how's it going?") { + response->reply = "all good!"; + } else { + response->reply = "don't know"; + } + } + + void publish_status() { + drake_ros_apps_msgs::msg::Status msg; + msg.stamp = this->get_clock()->now(); + msg.status.sequence_id = sequence_id_++; + msg.status.message = "OK"; + msg.origin = "oracle"; + status_pub_->publish(msg); + } + + std::random_device rd_; + std::mt19937 gen_{rd_()}; + std::bernoulli_distribution is_rite_complete_{0.1}; + + uint64_t sequence_id_{0u}; + rclcpp::Time action_start_time_; + std::shared_ptr action_loop_; + std::shared_ptr status_timer_; + std::shared_ptr> + status_pub_; + std::shared_ptr> + query_server_; + std::shared_ptr> + action_server_; +}; + +} // namespace drake_ros_apps + +int main(int argc, char** argv) { + rclcpp::init(argc, argv); + + rclcpp::spin(std::make_shared()); + + rclcpp::shutdown(); +} diff --git a/drake_ros_bazel_installed/drake_ros_apps/oracle.py b/drake_ros_bazel_installed/drake_ros_apps/oracle.py new file mode 100644 index 000000000..6b2b22645 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/oracle.py @@ -0,0 +1,100 @@ +#!/usr/bin/env python3 + +import random + +import rclpy +import rclpy.action +import rclpy.duration +import rclpy.node +import rclpy.qos + +import drake_ros_apps_msgs.msg +import drake_ros_common_msgs.action +import drake_ros_common_msgs.srv + + +class Oracle(rclpy.node.Node): + + def __init__(self): + super().__init__('oracle') + self._sequence_id = 0 + self._status_pub = self.create_publisher( + drake_ros_apps_msgs.msg.Status, 'status', + rclpy.qos.QoSProfile(depth=1)) + self._query_server = self.create_service( + drake_ros_common_msgs.srv.Query, 'query', self._handle_query) + self._action_server = rclpy.action.ActionServer( + self, drake_ros_common_msgs.action.Do, 'do', + execute_callback=self._handle_rite_action, + goal_callback=self._handle_action_request, + cancel_callback=self._handle_cancelled_action, + ) + self._status_timer = self.create_timer(1.0, self._publish_status) + + def _handle_action_request(self, goal): + if goal.action != 'rite': + self.get_logger().warning( + "Don't know how to " + goal.action) + return rclpy.action.GoalResponse.REJECT + return rclpy.action.GoalResponse.ACCEPT + + def _handle_cancelled_action(self, handle): + return rclpy.action.CancelResponse.ACCEPT + + def _handle_rite_action(self, handle): + result = drake_ros_common_msgs.action.Do.Result() + timeout = rclpy.duration.Duration.from_msg( + handle.request.timeout) + period = rclpy.duration.Duration.from_msg( + handle.request.period) + period = period.nanoseconds / 1e9 + start_time = self.get_clock().now() + rate = self.create_rate(1 / period) + while rclpy.ok(): + if handle.is_cancel_requested: + handle.canceled() + break + current_time = self.get_clock().now() + if current_time - start_time > timeout: + result.reason = 'timeout' + handle.abort() + break + if bool(random.getrandbits(1)): + handle.succeed() + break + feedback = drake_ros_common_msgs.action.Do.Feedback() + feedback.message = 'chanting' + handle.publish_feedback(feedback) + rate.sleep() + return result + + def _handle_query(self, request, response): + if request.query == "how's it going?": + response.reply = 'all good!' + else: + response.reply = "don't know" + return response + + def _publish_status(self): + msg = drake_ros_apps_msgs.msg.Status() + msg.status.sequence_id = self._sequence_id + self._sequence_id = self._sequence_id + 1 + msg.status.message = 'OK' + msg.origin = 'oracle' + self._status_pub.publish(msg) + + +def main(): + rclpy.init() + + try: + executor = rclpy.executors.MultiThreadedExecutor() + rclpy.spin(Oracle(), executor=executor) + except KeyboardInterrupt: + pass + finally: + rclpy.shutdown() + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/drake_ros_apps/test/exec.sh b/drake_ros_bazel_installed/drake_ros_apps/test/exec.sh new file mode 100755 index 000000000..0daad140e --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/test/exec.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec $@ diff --git a/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.gdb b/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.gdb new file mode 100644 index 000000000..149ec45e8 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.gdb @@ -0,0 +1,7 @@ +set breakpoint pending on +break main +run +continue +break Oracle::publish_status +continue +bt diff --git a/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.lldb b/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.lldb new file mode 100644 index 000000000..54701f54d --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_apps/test/oracle_cc.lldb @@ -0,0 +1,5 @@ +settings set target.process.stop-on-exec false +process launch --stop-at-entry +breakpoint set --method 'Oracle::publish_status' +continue +thread backtrace diff --git a/drake_ros_bazel_installed/drake_ros_common/BUILD.bazel b/drake_ros_bazel_installed/drake_ros_common/BUILD.bazel new file mode 100644 index 000000000..806512497 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_common/BUILD.bazel @@ -0,0 +1,18 @@ +# -*- mode: python -*- +# vi: set ft=python : + +load("@ros2//:rosidl.bzl", "rosidl_interfaces_group") + +rosidl_interfaces_group( + name = "drake_ros_common_msgs", + interfaces = [ + "msg/Status.msg", + "srv/Query.srv", + "action/Do.action", + ], + deps = [ + "@ros2//:action_msgs", + "@ros2//:builtin_interfaces", + ], + visibility = ["//visibility:public"], +) diff --git a/drake_ros_bazel_installed/drake_ros_common/README.md b/drake_ros_bazel_installed/drake_ros_common/README.md new file mode 100644 index 000000000..9128c1fce --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_common/README.md @@ -0,0 +1,5 @@ +## Common `drake_ros` interfaces + +This package exercises `rosidl` interface generation in C++ and Python. + +**Note**: this package does not host utilities but isolates commonalities among examples, enabling reuse. diff --git a/drake_ros_bazel_installed/drake_ros_common/action/Do.action b/drake_ros_bazel_installed/drake_ros_common/action/Do.action new file mode 100644 index 000000000..ac9667a22 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_common/action/Do.action @@ -0,0 +1,7 @@ +string action +builtin_interfaces/Duration period +builtin_interfaces/Duration timeout +--- +string reason +--- +string message diff --git a/drake_ros_bazel_installed/drake_ros_common/msg/Status.msg b/drake_ros_bazel_installed/drake_ros_common/msg/Status.msg new file mode 100644 index 000000000..ffa19e47a --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_common/msg/Status.msg @@ -0,0 +1,2 @@ +uint64 sequence_id +string message diff --git a/drake_ros_bazel_installed/drake_ros_common/srv/Query.srv b/drake_ros_bazel_installed/drake_ros_common/srv/Query.srv new file mode 100644 index 000000000..911e03928 --- /dev/null +++ b/drake_ros_bazel_installed/drake_ros_common/srv/Query.srv @@ -0,0 +1,3 @@ +string query +--- +string reply diff --git a/drake_ros_bazel_installed/setup/install_prereqs.sh b/drake_ros_bazel_installed/setup/install_prereqs.sh new file mode 100755 index 000000000..d7929b5ae --- /dev/null +++ b/drake_ros_bazel_installed/setup/install_prereqs.sh @@ -0,0 +1,68 @@ +#!/bin/bash + +set -eux pipefail + +apt update && apt install apt-transport-https curl gnupg lsb-release cmake build-essential gettext-base coreutils + +# Install Bazel (derived from setup/ubuntu/source_distribution/install_prereqs.sh at +# https://github.com/RobotLocomotion/drake/tree/e91e62f524788081a8fd231129b64ff80607c1dd) +function dpkg_install_from_curl() { + package="$1" + version="$2" + url="$3" + checksum="$4" + + installed_version=$(dpkg-query --showformat='${Version}\n' --show "${package}" 2>/dev/null || true) + if [[ "${installed_version}" != "" ]]; then + # Skip the install if we're already at the exact version. + if [[ "${installed_version}" == "${version}" ]]; then + echo "${package} is already at the desired version ${version}" + return + fi + + # If installing our desired version would be a downgrade or an upgrade, ask the user first. + echo "This system has ${package} version ${installed_version} installed." + action="upgrade" # Assume an upgrade + if dpkg --compare-versions "${installed_version}" gt "${version}"; then + action="downgrade" # Switch to a downgrade + fi + echo "Drake ROS intends to ${action} to version ${version}, the supported version." + read -r -p "Do you want to ${action}? [Y/n] " reply + if [[ ! "${reply}" =~ ^([yY][eE][sS]|[yY])*$ ]]; then + echo "Skipping ${package} ${version} installation." + return + fi + fi + # Download and verify. + tmpdeb="/tmp/${package}_${version}-amd64.deb" + curl -sSL "${url}" -o "${tmpdeb}" + if echo "${checksum} ${tmpdeb}" | sha256sum -c -; then + echo # Blank line between checkout output and dpkg output. + else + echo "ERROR: The ${package} deb does NOT have the expected SHA256. Not installing." >&2 + exit 2 + fi + + # Install. + dpkg -i "${tmpdeb}" + rm "${tmpdeb}" +} + +apt install g++ unzip zlib1g-dev + +dpkg_install_from_curl \ + bazel 4.2.1 \ + https://releases.bazel.build/4.2.1/release/bazel_4.2.1-linux-x86_64.deb \ + 67447658b8313316295cd98323dfda2a27683456a237f7a3226b68c9c6c81b3a + +# Install ROS 2 Rolling +curl -sSL https://raw.githubusercontent.com/ros/rosdistro/master/ros.key -o /usr/share/keyrings/ros-archive-keyring.gpg +echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/ros-archive-keyring.gpg] http://packages.ros.org/ros2/ubuntu $(lsb_release -cs) main" | tee /etc/apt/sources.list.d/ros2.list +apt update && apt install ros-rolling-ros-base ros-rolling-rmw-fastrtps-cpp ros-rolling-rmw-cyclonedds-cpp +apt install --only-upgrade $(dpkg-query -f '${Package}\n' -W 'ros-rolling-*') # NOTE(hidmic): avoid half upgrades (shouldn't the ros-rolling-ros-base install prevent that?) + +# Install Python dependencies +apt install python3 python3-toposort python3-dev python-is-python3 + +# Install debuggers +apt install gdb lldb diff --git a/drake_ros_bazel_installed/tools/BUILD.bazel b/drake_ros_bazel_installed/tools/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/skylark/BUILD.bazel b/drake_ros_bazel_installed/tools/skylark/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/skylark/dload.bzl b/drake_ros_bazel_installed/tools/skylark/dload.bzl new file mode 100644 index 000000000..cd9e4a581 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/dload.bzl @@ -0,0 +1,141 @@ +# -*- python -*- + +""" +The purpose of these macros is to support configuration of the runtime +environment in which executables are run. + +The primary macro of interest is `do_dload_shim`, which aids language +specific shim generation. +""" + +MAGIC_VARIABLES = { + "${LOAD_PATH}": "LD_LIBRARY_PATH" # for Linux +} + +def _unique(input_list): + """Extracts unique values from input list, while preserving their order.""" + output_list = [] + for item in input_list: + if item not in output_list: + output_list.append(item) + return output_list + +def _normpath(path): + """ + Normalizes a path by removing redundant separators and up-level references. + + Equivalent to Python's os.path.normpath(). + """ + path_parts = path.split("/") + normalized_path_parts = [path_parts[0]] + for part in path_parts[1:]: + if part == "." or part == "": + continue + if part == "..": + normalized_path_parts.pop() + continue + normalized_path_parts.append(part) + return "/".join(normalized_path_parts) + +def get_dload_shim_attributes(): + """ + Yields attributes common to all dload_shim-based rules. + + This macro aids rule declaration and as such it is not meant + to be used in any other context (like a BUILD.bazel file). + """ + return { + "target": attr.label( + mandatory = True, + allow_files = True, + executable = True, + cfg = "target", + ), + "env_changes": attr.string_list_dict(), + } + +def do_dload_shim(ctx, template, to_list): + """ + Implements common dload_shim rule functionality. + + This macro is a parametrized rule implementation and as such it is not meant + to be used in any other context (like a BUILD.bazel file). + + Args: + ctx: context of a Bazel rule + template: string template for the shim + to_list: macro for list interpolation + + It expects the following attributes on ctx: + + target: executable target to be shimmed + env_changes: runtime environment changes to be applied, as a mapping from + environment variable names to actions to be performed on them. + Actions are (action_type, action_args) tuples. Supported action types + are: 'path-prepend', 'path-replace', and 'replace'. Paths are resolved + relative to the runfiles directory of the downstream executable. + Also, see MAGIC_VARIABLES for platform-independent runtime environment + specification. + + You may use get_dload_shim_attributes() on rule definition. + """ + executable_file = ctx.executable.target + + env_changes = { + MAGIC_VARIABLES.get(name, default=name): + _parse_runtime_environment_action(ctx, action) + for name, action in ctx.attr.env_changes.items() + } + envvars = env_changes.keys() + actions = env_changes.values() + + shim_content = template.format( + # Deal with usage in external workspaces' BUILD.bazel files + executable_path=_normpath("{}/{}".format( + ctx.workspace_name, executable_file.short_path + )), + names=to_list([repr(name) for name in envvars]), + actions=to_list([ + to_list([ + repr(field) for field in action + ]) for action in actions + ]), + ) + shim = ctx.actions.declare_file(ctx.label.name) + ctx.actions.write(shim, shim_content, True) + return [DefaultInfo( + files = depset([shim]), + data_runfiles = ctx.runfiles(files = [shim]), + )] + +def _resolve_runfile_path(ctx, path): + """ + Resolves a package relative path into an (expected) runfiles directory + relative path. + + All `$(rootpath...)` templates in the given path, if any, will be expanded. + """ + return _normpath(ctx.expand_location(path)) + +def _parse_runtime_environment_action(ctx, action): + """ + Parses a runtime environment action, validating types and resolving paths. + """ + action_type, action_args = action[0], action[1:] + if action_type == "path-prepend": + if len(action_args) == 0: + tpl = "'{}' action requires at least one argument" + fail(msg = tpl.format(action_type)) + action_args = _unique([ + _resolve_runfile_path(ctx, path[:-1]) + "!" if path.endswith("!") + else _resolve_runfile_path(ctx, path) for path in action_args + ]) + elif action_type in ("path-replace", "replace"): + if len(action_args) != 1: + tpl = "'{}' action requires exactly one argument" + fail(msg = tpl.format(action_type)) + if action_type.startswith("path"): + action_args = [_resolve_runfile_path(ctx, action_args[0])] + else: + fail(msg = "'{}' action is unknown".format(action_type)) + return [action_type] + action_args diff --git a/drake_ros_bazel_installed/tools/skylark/dload_cc.bzl b/drake_ros_bazel_installed/tools/skylark/dload_cc.bzl new file mode 100644 index 000000000..d91795822 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/dload_cc.bzl @@ -0,0 +1,109 @@ +# -*- python -*- + +""" +The purpose of these rules is to support the propagation of runtime information +that is necessary for the execution of C/C++ binaries and tests that require it. +""" + +load( + "//tools/skylark:dload.bzl", + "do_dload_shim", + "get_dload_shim_attributes", +) + +_DLOAD_CC_SHIM_TEMPLATE = """\ +#include +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "tools/cpp/runfiles/runfiles.h" + +using bazel::tools::cpp::runfiles::Runfiles; + +int main(int argc, const char * argv[]) {{ + std::string error; + std::unique_ptr runfiles(Runfiles::Create(argv[0], &error)); + if (!runfiles) {{ + std::cerr << "ERROR: " << error << std::endl; + return -1; + }} + + std::vector names = {names}; + std::vector> actions = {actions}; // NOLINT + for (size_t i = 0; i < names.size(); ++i) {{ + std::stringstream value_stream; + if (actions[i][0] == "replace") {{ + assert(actions[i].size() == 2); + value_stream << actions[i][1]; + }} else if (actions[i][0] == "path-replace") {{ + assert(actions[i].size() == 2); + value_stream << runfiles->Rlocation(actions[i][1]); + }} else if (actions[i][0] == "path-prepend") {{ + assert(actions[i].size() >= 2); + for (size_t j = 1; j < actions[i].size(); ++j) {{ + value_stream << runfiles->Rlocation(actions[i][j]) << ":"; + }} + + const char * raw_value = getenv(names[i].c_str()); + if (raw_value != nullptr) {{ + value_stream << raw_value; + }} + }} else {{ + assert(false); // should never get here + }} + std::string value = value_stream.str(); + + std::string::size_type location; + if ((location = value.find("$PWD")) != std::string::npos) {{ + value.replace(location, 4, std::filesystem::current_path()); + }} + + if (setenv(names[i].c_str(), value.c_str(), 1) != 0) {{ + std::cerr << "ERROR: failed to set " << names[i] << std::endl; + }} + }} + + const std::string executable_path = + runfiles->Rlocation("{executable_path}"); // NOLINT + + char ** other_argv = new char*[argc + 1]; + other_argv[0] = strdup(executable_path.c_str()); + for (int i = 1; i < argc; ++i) {{ + other_argv[i] = strdup(argv[i]); + }} + other_argv[argc] = NULL; + int ret = execv(other_argv[0], other_argv); + // What follows applies if and only if execv() itself fails + // (e.g. can't find the binary) and returns control + std::cout << "ERROR: " << strerror(errno) << std::endl; + return ret; +}} +""" + +def _to_cc_list(collection): + """Turn collection into a C++ aggregate initializer expression.""" + return "{" + ", ".join(collection) + "}" + +def _dload_cc_shim_impl(ctx): + return do_dload_shim(ctx, _DLOAD_CC_SHIM_TEMPLATE, _to_cc_list) + +dload_cc_shim = rule( + attrs = get_dload_shim_attributes(), + implementation = _dload_cc_shim_impl, + output_to_genfiles = True, +) +""" +Generates a C++ shim that can inject runtime environment information for +C/C++ binaries that have such requirements. Using a C++ shim for C++ binaries +simplifies UX during debugging sessions, as fork-follow behavior in common +debuggers like gdb and lldb makes it transparent. + +See do_dload_shim() documentation for further reference. +""" diff --git a/drake_ros_bazel_installed/tools/skylark/dload_py.bzl b/drake_ros_bazel_installed/tools/skylark/dload_py.bzl new file mode 100644 index 000000000..7cb5d4e26 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/dload_py.bzl @@ -0,0 +1,78 @@ +# -*- python -*- +# vi: set ft=python : + +""" +The purpose of these rules is to support the propagation of runtime information +that is necessary for the execution of Python binaries and tests that require it. +""" + +load( + "//tools/skylark:dload.bzl", + "do_dload_shim", + "get_dload_shim_attributes", +) + +_DLOAD_PY_SHIM_TEMPLATE = """\ +import os +import sys + +from bazel_tools.tools.python.runfiles import runfiles + + +def main(argv): + r = runfiles.Create() + # NOTE(hidmic): unlike its C++ equivalent, Python runfiles' + # builtin tools will only look for runfiles in the manifest + # if there is a manifest + runfiles_dir = r.EnvVars()['RUNFILES_DIR'] + + def rlocation(path): + return r.Rlocation(path) or os.path.join(runfiles_dir, path) + + for name, action in zip({names}, {actions}): # noqa + action_type, action_args = action[0], action[1:] + if action_type == 'replace': + assert len(action_args) == 1 + value = action_args[0] + elif action_type == 'path-replace': + assert len(action_args) == 1 + value = rlocation(action_args[0]) + elif action_type == 'path-prepend': + assert len(action_args) > 0 + value = ':'.join([rlocation(path) for path in action_args]) + if name in os.environ: + value += ':' + os.environ[name] + else: + assert False # should never get here + if '$PWD' in value: + value = value.replace('$PWD', os.getcwd()) + os.environ[name] = value + + executable_path = r.Rlocation('{executable_path}') # noqa + argv = [executable_path] + argv[1:] + os.execv(executable_path, argv) + + +if __name__ == '__main__': + main(sys.argv) +""" + +def _to_py_list(collection): + """Turn collection into a Python list expression.""" + return "[" + ", ".join(collection) + "]" + +def _dload_py_shim_impl(ctx): + return do_dload_shim(ctx, _DLOAD_PY_SHIM_TEMPLATE, _to_py_list) + +dload_py_shim = rule( + attrs = get_dload_shim_attributes(), + output_to_genfiles = True, + implementation = _dload_py_shim_impl, +) +""" +Generates a Python shim that can inject runtime environment information for +Python binaries that have such requirements. Using a Python shim for Python +binaries enables downstream usage of the latter through transitive dependencies. + +See do_dload_shim() documentation for further reference. +""" diff --git a/drake_ros_bazel_installed/tools/skylark/execute.bzl b/drake_ros_bazel_installed/tools/skylark/execute.bzl new file mode 100644 index 000000000..95d8eea54 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/execute.bzl @@ -0,0 +1,19 @@ +# -*- python -*- + +def execute_or_fail(repo_ctx, cmd, **kwargs): + exec_result = repo_ctx.execute(cmd, **kwargs) + if exec_result.return_code != 0: + error_message ="'{}' exited with {}".format( + " ".join([str(token) for token in cmd]), + exec_result.return_code + ) + if exec_result.stdout: + error_message += "\n--- captured stdout ---\n" + error_message += exec_result.stdout + if exec_result.stderr: + error_message += "\n--- captured stderr ---\n" + error_message += exec_result.stderr + fail("Failed to setup @{} repository: {}".format( + repo_ctx.name, error_message + )) + return exec_result diff --git a/drake_ros_bazel_installed/tools/skylark/kwargs.bzl b/drake_ros_bazel_installed/tools/skylark/kwargs.bzl new file mode 100644 index 000000000..3f471e4ea --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/kwargs.bzl @@ -0,0 +1,32 @@ +# -*- python -*- + +_COMMON_KWARGS = [ + "compatible_with", + "deprecation", + "exec_compatible_with", + "exec_properties", + "features", + "restricted_to", + "tags", + "target_compatible_with", + "testonly", + "toolchains", + "visibility", +] + +def filter_to_only_common_kwargs(kwargs): + """Fetch keyword arguments common to all rules from `kwargs`.""" + return {key: value for key, value in kwargs.items() if key in _COMMON_KWARGS} + +_TEST_KWARGS = [ + "env_inherit", + "flaky", + "local", + "shard_count", + "size", + "timeout", +] + +def remove_test_specific_kwargs(kwargs): + """Filter keyword arguments specific to test rules from `kwargs`.""" + return {key: value for key, value in kwargs.items() if key not in _TEST_KWARGS} diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/BUILD.bazel b/drake_ros_bazel_installed/tools/skylark/ros2/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/README.md b/drake_ros_bazel_installed/tools/skylark/ros2/README.md new file mode 100644 index 000000000..1eea4f28c --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/README.md @@ -0,0 +1,120 @@ +## Infrastructure to use ROS 2 from a Bazel workspace + +This package encapsulates all the machinery to pull a ROS 2 workspace install +space or a subset thereof as a Bazel repository. Both system-installed binary +distributions and source builds can pulled in this way, whether symlink- or +merged-installed. + +A single repository rule, `ros2_local_repository()`, is the sole entrypoint. +This rule heavily relies on two Python packages: + +- The `cmake_tools` Python package, which provides an idiomatic API to collect + a CMake project's exported configuration (see Note 1). +- The `ros2bzl` Python package, which provides tools to crawl a ROS 2 workspace + install space, collects CMake packages' exported configuration, collect Python + packages' egg metadata, symlink relevant directories and files, and generates + a root BUILD.bazel file that recreates the dependency graph in the workspace. + This package constitutes the backbone of the `generate_repository_files.py` + Python binary which `ros2_local_repository()` invokes. + +**Note 1** +: [`rules_foreign_cc`](https://github.com/bazelbuild/rules_foreign_cc) tooling + was initially considered but later discarded, as it serves a fundamentally + different purpose. This infrastructure does **not** build CMake projects + within Bazel (like `rules_foreign_cc` does), it exposes the artifacts and + build configuration of pre-installed CMake projects for Bazel packages to + depend on. + +### Repository layout + +A ROS 2 local repository has the following layout: + +``` + . + ├── BUILD.bazel + ├── rmw_isolation + │ └── BUILD.bazel + ├── distro.bzl + ├── common.bzl + ├── ros_cc.bzl + ├── ros_py.bzl + └── rosidl.bzl +``` + +Note that all files and subdirectories that are mere implementation details +have been excluded from this layout. + +#### Targets + +For each package in the underlying ROS 2 workspace install space, depending on +the artifacts it generates, the following targets may be found at the root +`BUILD.bazel` file: + +- A `_share` filegroup for files in the `/share` + directory but excluding those files that are build system specific. +- A `_cc` C/C++ library for C++ libraries, typically found in + the `/lib` directory. In addition to headers, compiler flags, + linker flags, etc., C/C++ library targets for upstream packages that are + immediate dependencies are declared as dependencies themselves. +- A `_py` Python library for Python eggs in the + `/lib/python*/site-packages` directory. All Python library + targets for upstream packages that are immediate dependencies are declared + as dependencies themselves. +- A `_defs` filegroup for interface definition files (.msg, .srv, + .action, and .idl files) in the `/share` directory. +- A `_c` C/C++ library for C libraries. Typically an alias of the + `_cc` target if C and C++ libraries cannot be told apart. +- A `_transitive_py` Python library if the package does not + install any Python libraries but it depends on (and it is a dependency of) + packages that do. This helps maintain the dependency graph (as Python library + targets can only depend on other Python library targets). +- A `_` Python binary for each executable + installed at the package-level (i.e. under `lib/`, where + `ros2 run` can find them). +- An `` Python binary wrapper for each executable of *any* + kind (Python, shell script, compiled binary, etc.) installed under the + `/bin` directory (and thus accessible via `$PATH` when + sourcing the workspace install space). These executables are exposed as + Python binaries for simplicty. + +#### Rules + +To build C++ binaries and tests that depend on ROS 2, `ros_cc_binary` and +`ros_cc_test` rules are available in the `ros_cc.bzl` file. These rules, +equivalent to the native `cc_binary` and `cc_test` rules, ensure these binaries +run in an environment that is tightly coupled with the underlying ROS 2 +workspace install space. + +To build Python binaries and tests that depend on ROS 2, `ros_py_binary` and +`ros_pytest` rules are available in the `ros_py.bzl` file. These rules, +equivalent to the native `py_binary` and `py_test` rules, ensure these binaries +run in an environment that is tightly coupled with the underlying ROS 2 +workspace install space. + +To generate and build ROS 2 interfaces, a `rosidl_interfaces_group` rule is +available in the `rosidl.bzl` file. This rule generates C++ and Python code +for the given ROS 2 interface definition files and builds them using what is +available in the ROS 2 workspace install space: code generators, interface +definition translators, runtime dependencies, etc. Several targets are created, +following strict naming conventions (e.g. C++ and Python interface libraries +carry `_cc` and `_py` suffixes, respectively), though finer-grained control over +what is generated and built can be achieved through other rules available in +the same file. By default, these naming conventions allow downstream +`rosidl_interfaces_group` rules to depend on upstream `rosidl_interface_group` +rules. + +#### Tools + +The `rmw_isolation` subpackage provides C++ and Python `isolate_rmw_by_path` +APIs to enforce RMW network isolation. To that end, a unique path must be +provided (such as Bazel's `$TEST_TMPDIR`). + +**DISCLAIMER** +: Isolation relies on `rmw`-specific configuration. Support is available for + Tier 1 `rmw` implementations only. Collision rates are below 1% but not null. + Use with care. + +#### Metadata + +The `distro.bzl` file bears relevant ROS 2 workspace metadata for rules, tools, +and downstream packages to use. diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/__init__.py b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/__init__.py new file mode 100644 index 000000000..d67e488cd --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/__init__.py @@ -0,0 +1,27 @@ +import os +import shutil +import subprocess + +from .packages import get_packages_with_prefixes +from .server_mode import server_mode + + +def configure_file(src, dest, subs): + with open(src, 'r') as f: + text = f.read() + for old, new in subs.items(): + text = text.replace(old, new) + with open(dest, 'w') as f: + f.write(text) + + +def build_then_install(project_path, *args, build_path='build'): + if not os.path.isabs(build_path): + build_path = os.path.join(project_path, build_path) + if os.path.exists(build_path): + shutil.rmtree(build_path) + os.makedirs(build_path) + subprocess.run([ + 'cmake', '-G', 'Unix Makefiles', *args, project_path + ], cwd=build_path, check=True) + subprocess.run(['make', 'install'], cwd=build_path, check=True) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/packages.py b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/packages.py new file mode 100644 index 000000000..2bb5179fd --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/packages.py @@ -0,0 +1,18 @@ +import glob +import os + + +def get_packages_with_prefixes(prefixes=None): + if prefixes is None: + prefixes = os.environ['CMAKE_PREFIX_PATH'].split(os.pathsep) + prefixes = [prefix for prefix in prefixes if prefix] + suffixes = 'Config.cmake', '-config.cmake' + return { + os.path.basename(path)[:-len(suffix)]: prefix + for prefix in prefixes for suffix in suffixes + for path in glob.glob( + '{}/**/*{}'.format( + prefix, suffix), + recursive=True + ) + } diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/server_mode.py b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/server_mode.py new file mode 100644 index 000000000..6e29d9ff5 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/cmake_tools/server_mode.py @@ -0,0 +1,195 @@ +import contextlib +import functools +import json +import os +import re +import selectors +import signal +import socket +import subprocess +import time +import tempfile + + +class CMakeServer: + + START_MAGIC_STRING = '[== "CMake Server" ==[' + END_MAGIC_STRING = ']== "CMake Server" ==]' + + def __init__(self, address=None): + if not address: + address = tempfile.mktemp() + self._address = address + cmd = ['cmake', '-E', 'server', '--experimental'] + cmd.append('--pipe=' + self._address) + self._process = subprocess.Popen(cmd) + while not os.path.exists(self._address): + if self._process.poll() is not None: + break + time.sleep(1) + + @property + def address(self): + return self._address + + def shutdown(self, timeout=None): + self._process.send_signal(signal.SIGINT) + try: + self._process.wait(timeout) + except subprocess.TimeoutExpired: + self._process.kill() + + +class CMakeClient: + + def __init__(self, address): + self._socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + try: + self._socket.connect(address) + self._selector = selectors.DefaultSelector() + self._selector.register(self._socket, selectors.EVENT_READ) + self._unpack_buffer = '' + + message = next(self._receive(timeout=5), None) + assert message + assert message['type'] == 'hello' + self._supported_protocol_versions = \ + message['supportedProtocolVersions'] + except Exception: + self._socket.close() + raise + + @property + def supported_protocol_versions(self): + return self._supported_protocol_versions + + def shutdown(self): + self._socket.close() + + def _pack(self, payload): + return '\n'.join([ + CMakeServer.START_MAGIC_STRING, + json.dumps(payload), + CMakeServer.END_MAGIC_STRING + ]) + '\n' + + def _unpack(self, partial): + self._unpack_buffer += partial + while self._unpack_buffer: + packet_start_index = \ + self._unpack_buffer.find(CMakeServer.START_MAGIC_STRING) + if packet_start_index < 0: + break + payload_start_index = \ + packet_start_index + len(CMakeServer.START_MAGIC_STRING) + payload_end_index = self._unpack_buffer.find( + CMakeServer.END_MAGIC_STRING, payload_start_index + ) + if payload_end_index < 0: + break + payload = json.loads( + self._unpack_buffer[payload_start_index:payload_end_index] + ) + packet_end_index = \ + payload_end_index + len(CMakeServer.END_MAGIC_STRING) + self._unpack_buffer = self._unpack_buffer[packet_end_index:] + + assert type(payload) is dict + assert 'type' in payload + yield payload + + def _send(self, payload): + packet = self._pack(payload).encode('utf-8') + written = self._socket.send(packet) + return len(packet) == written + + def _receive(self, timeout=None): + if timeout is not None: + deadline = time.time() + timeout + while True: + events = self._selector.select(timeout) + for key, mask in events: + assert key.fileobj is self._socket + assert mask & selectors.EVENT_READ + partial = self._socket.recv(4096) + yield from self._unpack(partial.decode('utf-8')) + if timeout is not None: + current_time = time.time() + if current_time >= deadline: + break + timeout = deadline - current_time + + def exchange( + self, + request, + message_callback=None, + progress_callback=None, + **kwargs + ): + assert self._send(request) + for response in self._receive(**kwargs): + if response['type'] == 'signal': + # Ignore signal responses + continue + if response['inReplyTo'] != request['type']: + continue + if response['type'] == 'reply': + return response + if response['type'] == 'error': + raise RuntimeError(response['errorMessage']) + if response['type'] == 'message': + if message_callback: + message_callback(response['message']) + continue + if response['type'] == 'progress': + if progress_callback: + message = response['progressMessage'] + progress = ( + response['progressMinimum'], + response['progressMaximum'], + response['progressCurrent'] + ) + progress_callback(message, progress) + raise TimeoutError( + 'Timeout waiting for reply to {!r} request'.format(request) + ) + + def _request_method(self, type_, attributes=None, **kwargs): + request = {'type': type_} + if attributes: + request.update(attributes) + reply = self.exchange(request, **kwargs) + return { + k: v for k, v in reply.items() + # Drop transport specific attributes + if k not in ('inReplyTo', 'type', 'cookie') + } + + def __getattr__(self, name): + return functools.partial(self._request_method, name) + + +@contextlib.contextmanager +def server_mode(project_path): + with tempfile.TemporaryDirectory() as tmpdir: + cmake_server = CMakeServer(address=os.path.join(tmpdir, 'pipe')) + try: + cmake = CMakeClient(cmake_server.address) + try: + supported_versions = cmake.supported_protocol_versions + assert any(v['major'] == 1 for v in supported_versions) + project_build_path = os.path.join(project_path, 'build') + cmake.handshake( + attributes={ + 'sourceDirectory': os.path.abspath(project_path), + 'buildDirectory': os.path.abspath(project_build_path), + 'generator': 'Unix Makefiles', + 'protocolVersion': {'major': 1} + }, + timeout=5 + ) + yield cmake + finally: + cmake.shutdown() + finally: + cmake_server.shutdown() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/generate_repository_files.py b/drake_ros_bazel_installed/tools/skylark/ros2/generate_repository_files.py new file mode 100755 index 000000000..c6a702224 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/generate_repository_files.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 + +""" +Scrapes ROS 2 workspaces and exposes their artifacts through a Bazel local repository. + +This script generates: + +- a BUILD.bazel file, with targets for all C/C++ libraries, Python libraries, executables, and share + data files found in each scrapped ROS 2 package +- a distro.bzl file, with ROS 2 metadata as constants +""" + +import argparse +import collections +import os +import sys +import xml.etree.ElementTree as ET + +import toposort + +# NOTE: this script is typically invoked from the `ros2_local_repository()` +# repository rule. As repository rules are executed in Bazel's loading phase, +# `py_library()` cannot be relied on to make modules such as `ros2bzl` and +# `cmake_tools` reachable through PYTHONPATH. Thus, we force it here. +sys.path.insert(0, os.path.dirname(__file__)) # noqa + +from ros2bzl.resources import load_resource + +import ros2bzl.sandboxing as sandboxing + +from ros2bzl.scraping import load_distribution +from ros2bzl.scraping.ament_cmake \ + import collect_ament_cmake_package_direct_properties +from ros2bzl.scraping.ament_cmake \ + import collect_ament_cmake_package_properties +from ros2bzl.scraping.ament_cmake import precache_ament_cmake_properties +from ros2bzl.scraping.ament_python \ + import collect_ament_python_package_direct_properties +from ros2bzl.scraping.ament_python import PackageNotFoundError + +from ros2bzl.templates import configure_distro +from ros2bzl.templates import configure_executable_imports +from ros2bzl.templates import configure_package_c_library_alias +from ros2bzl.templates import configure_package_cc_library +from ros2bzl.templates import configure_package_executable_imports +from ros2bzl.templates import configure_package_interfaces_filegroup +from ros2bzl.templates import configure_package_meta_py_library +from ros2bzl.templates import configure_package_py_library +from ros2bzl.templates import configure_package_share_filegroup +from ros2bzl.templates import configure_prologue + +from ros2bzl.utilities import interpolate + + +def parse_arguments(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter + ) + parser.add_argument( + 'repository_name', help='Bazel repository name' + ) + parser.add_argument( + '-s', '--sandbox', action='append', + metavar='OUTER_PATH:INNER_PATH', default=[], + help='Path mappings for sandboxing' + ) + parser.add_argument( + '-i', '--include-package', action='append', + dest='include_packages', metavar='PACKAGE_NAME', default=[], + help='Packages to be included (plus their dependencies)' + ) + parser.add_argument( + '-e', '--exclude-package', action='append', + dest='exclude_packages', metavar='PACKAGE_NAME', default=[], + help='Packages to be explicitly excluded' + ) + parser.add_argument( + '-x', '--extras', metavar='LABEL.ATTR+=(LABEL|STRING)', + action='append', default=[], + help=('Additional dependencies for generated targets') + ) + parser.add_argument( + '-j', '--jobs', metavar='N', type=int, default=None, + help='Number of CMake jobs to use during package configuration and scraping' + ) + args = parser.parse_args() + + extras = {} + for item in args.extras: + lhs, _, rhs = item.partition('+=') + target_name, _, attribute_name = lhs.rpartition('.') + if attribute_name not in extras: + extras[attribute_name] = {} + if target_name not in extras[attribute_name]: + extras[attribute_name][target_name] = [] + extras[attribute_name][target_name].append(rhs) + args.extras = extras + + args.sandbox = sandboxing.make_symlink_forest_mapping( + name=args.repository_name, mapping=dict( + entry.partition(':')[0::2] for entry in args.sandbox + ) + ) + + return args + + +def generate_distro_file(repo_name, distro): + with open('distro.bzl', 'w') as fd: + template, config = configure_distro(repo_name, distro) + fd.write(interpolate(template, config) + '\n') + + +def generate_build_file(repo_name, distro, cache, extras, sandbox): + rmw_implementation_packages = { + name: metadata for name, metadata in distro['packages'].items() + if 'rmw_implementation_packages' in metadata.get('groups', []) + } + + with open('BUILD.bazel', 'w') as fd: + template, config = configure_prologue(repo_name) + fd.write(interpolate(template, config) + '\n') + + for name in toposort.toposort_flatten(distro['dependency_graph']): + metadata = distro['packages'][name] + + # Avoid linking non-direct dependencies (e.g. group dependencies) + direct_dependency_names = \ + distro['dependency_graph'][name].intersection( + metadata.get('build_export_dependencies', set()).union( + metadata.get('run_dependencies', set()) + ) + ) + direct_dependencies = { + dependency_name: distro['packages'][dependency_name] + for dependency_name in direct_dependency_names + } + + if 'share_directory' in metadata: + _, template, config = \ + configure_package_share_filegroup(name, metadata, sandbox) + fd.write(interpolate(template, config) + '\n') + + if 'rosidl_interface_packages' in metadata.get('groups', []): + _, template, config = \ + configure_package_interfaces_filegroup( + name, metadata, sandbox) + fd.write(interpolate(template, config) + '\n') + + if 'cmake' in metadata.get('build_type'): + properties = collect_ament_cmake_package_direct_properties( + name, metadata, direct_dependencies, cache + ) + + _, template, config = configure_package_cc_library( + name, metadata, properties, direct_dependencies, extras, sandbox + ) + + fd.write(interpolate(template, config) + '\n') + + if 'rosidl_interface_packages' in metadata.get('groups', []): + # Alias C++ library as C library for interface packages + # as their headers and artifacts cannot be discriminated. + _, template, config = \ + configure_package_c_library_alias(name, metadata) + fd.write(interpolate(template, config) + '\n') + + # No way to tell if there's Python code for this package + # but to look for it. + try: + properties = collect_ament_python_package_direct_properties( + name, metadata, direct_dependencies, cache + ) + # Add 'py' as language if not there. + if 'langs' not in metadata: + metadata['langs'] = set() + metadata['langs'].add('py') + except PackageNotFoundError: + if any('py' in metadata.get('langs', []) + for metadata in direct_dependencies.values() + ): + metadata['langs'].add('py (transitive)') + # Dependencies still need to be propagated. + _, template, config = configure_package_meta_py_library( + name, metadata, direct_dependencies) + fd.write(interpolate(template, config) + '\n') + + properties = {} + + if properties: + _, template, config = configure_package_py_library( + name, metadata, properties, direct_dependencies, extras, sandbox + ) + fd.write(interpolate(template, config) + '\n') + + if metadata.get('executables'): + direct_dependencies.update(rmw_implementation_packages) + for _, template, config in configure_package_executable_imports( + name, metadata, direct_dependencies, sandbox, extras=extras + ): + fd.write(interpolate(template, config) + '\n') + + for _, template, config in configure_executable_imports( + distro['executables'], distro['packages'], sandbox, extras=extras + ): + fd.write(interpolate(template, config) + '\n') + + +def main(): + args = parse_arguments() + + distro = load_distribution( + args.sandbox, + set(args.include_packages), + set(args.exclude_packages)) + + cache = { + 'ament_cmake': precache_ament_cmake_properties( + distro['packages'], jobs=args.jobs + ) + } + + generate_build_file( + args.repository_name, distro, + cache, args.extras, args.sandbox) + + generate_distro_file(args.repository_name, distro) + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/common.bzl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/common.bzl new file mode 100644 index 000000000..a3d503679 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/common.bzl @@ -0,0 +1,40 @@ +# -*- python -*- + +load(":distro.bzl", "REPOSITORY_ROOT") + +def share_filegroup(name, share_directories): + native.filegroup( + name = name, + srcs = [path for path in native.glob( + include = ["{}/**".format(dirpath) for dirpath in share_directories], + exclude = [ + "*/cmake/**", + "*/environment/**", + "*/*.sh", + "*/*.bash", + "*/*.dsv", + ] + ) if " " not in path], + # NOTE(hidmic): workaround lack of support for spaces. + # See https://github.com/bazelbuild/bazel/issues/4327. + ) + +def interfaces_filegroup(name, share_directory): + native.filegroup( + name = name + "_defs", + srcs = native.glob(include = [ + "{}/**/*.idl".format(share_directory), + "{}/**/*.msg".format(share_directory), + "{}/**/*.srv".format(share_directory), + "{}/**/*.action".format(share_directory), + ]) + ) + +def incorporate_rmw_implementation(kwargs, env_changes, rmw_implementation): + target = REPOSITORY_ROOT + ":%s_cc" % rmw_implementation + kwargs["data"] = kwargs.get("data", []) + [target] + env_changes = dict(env_changes) + env_changes.update({ + "RMW_IMPLEMENTATION": ["replace", rmw_implementation] + }) + return kwargs, env_changes diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/__init__.py b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/__init__.py new file mode 100644 index 000000000..16c094eb9 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/__init__.py @@ -0,0 +1 @@ +from .rmw_isolation import isolate_rmw_by_path diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/generate_isolated_rmw_env.py b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/generate_isolated_rmw_env.py new file mode 100644 index 000000000..163dd6c39 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/generate_isolated_rmw_env.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 + +""" +Generates a runtime environment for RMW isolation. + +Environment variables are written in the NAME=VALUE format, one per line, +suitable for e.g. the `env` command and the `setenv` API. + +This script may be used to achieve isolation in a shell environment. +For a C++ (resp. Python) API to easily leverage isolation from within +C++ (resp. Python) executables, see the `rmw_isolation_cc` +(resp. `rmw_isolation_py`) libray. +""" + +import argparse +import pathlib +import sys + +from rmw_isolation import generate_isolated_rmw_env + + +def main(argv=None): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument( + '-o', '--output', metavar='PATH', + type=argparse.FileType('w'), default=sys.stdout, + help='Path to output file. Defaults to stdout.') + parser.add_argument( + '-s', '--scratch-directory', metavar='PATH', + default=pathlib.Path.cwd(), + help=('Path to scratch directory for generated files, if any. ' + 'Defaults to the current working directory.')) + parser.add_argument( + '-r', '--rmw-implementation', default=None, + help=('Middleware implementation to be isolated. ' + 'Defaults to currently applicable implementation, ' + 'as returned by rclpy.get_rmw_implementation_identifier().')) + parser.add_argument( + 'unique_identifier', nargs='?', default=str(pathlib.Path.cwd()), + help=('Unique arbitrary identifier for isolation. ' + 'Defaults to current working directory.')) + args = parser.parse_args(argv) + for key, value in generate_isolated_rmw_env( + args.unique_identifier, + rmw_implementation=args.rmw_implementation, + scratch_directory=args.scratch_directory + ).items(): + args.output.write(f'{key}={value}\n') + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/package.BUILD.bazel b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/package.BUILD.bazel new file mode 100644 index 000000000..55cc7c9d2 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/package.BUILD.bazel @@ -0,0 +1,115 @@ +# -*- python -*- +# vi: set ft=python : + +load("//:ros_cc.bzl", "ros_cc_binary") +load("//:ros_py.bzl", "ros_py_binary") + +py_library( + name = "rmw_isolation_py", + srcs = ["rmw_isolation.py"], + deps = ["//:rclpy_py"], +) + +py_library( + name = "module_py", + srcs = ["__init__.py"], + deps = [":rmw_isolation_py"], +) + +ros_py_binary( + name = "generate_isolated_rmw_env", + srcs = ["generate_isolated_rmw_env.py"], + main = "generate_isolated_rmw_env.py", + deps = [":module_py"], + legacy_create_init = False, +) + +cc_library( + name = "rmw_isolation_cc", + srcs = ["rmw_isolation.cc"], + hdrs = ["rmw_isolation.h"], + data = [":generate_isolated_rmw_env"], + local_defines = [ + "RMW_ISOLATION_ROOTPATH={}/rmw_isolation".format( + repository_name().strip("@") or ".") + ], + deps = [ + "@bazel_tools//tools/cpp/runfiles", + "//:rclcpp_cc", + ], +) + +ros_py_binary( + name = "isolated_talker_py", + srcs = ["test/isolated_talker.py"], + main = "test/isolated_talker.py", + deps = [ + "//:rclpy_py", + "//:std_msgs_py" + ], + rmw_implementation = 'rmw_cyclonedds_cpp', + legacy_create_init = False, +) + +ros_py_binary( + name = "isolated_listener_py", + srcs = ["test/isolated_listener.py"], + main = "test/isolated_listener.py", + deps = [ + "//:rclpy_py", + "//:std_msgs_py", + ":module_py", + ], + rmw_implementation = 'rmw_cyclonedds_cpp', + legacy_create_init = False, +) + +sh_test( + name = "rmw_isolation_py_test", + srcs = ["test/rmw_isolation_test.sh"], + args = [ + "$(location :isolated_talker_py)", + "$(location :isolated_listener_py)", + ], + data = [":isolated_talker_py", ":isolated_listener_py"], + size = "small", + # Set flaky because collision rates are non-zero, + # See ROS2 Skylark tooling README.md for further reference. + flaky = True, +) + +ros_cc_binary( + name = "isolated_talker_cc", + srcs = ["test/isolated_talker.cc"], + deps = [ + "//:rclcpp_cc", + "//:std_msgs_cc", + ":rmw_isolation_cc", + ], + rmw_implementation = 'rmw_cyclonedds_cpp', +) + +ros_cc_binary( + name = "isolated_listener_cc", + srcs = ["test/isolated_listener.cc"], + deps = [ + "//:rclcpp_cc", + "//:std_msgs_cc", + ":rmw_isolation_cc", + ], + rmw_implementation = 'rmw_cyclonedds_cpp', +) + +sh_test( + name = "rmw_isolation_cc_test", + srcs = ["test/rmw_isolation_test.sh"], + args = [ + "$(location :isolated_talker_cc)", + "$(location :isolated_listener_cc)", + ], + data = [":isolated_talker_cc", ":isolated_listener_cc"], + size = "small", + # Set flaky because collision rates are non-zero, + # See ROS2 Skylark tooling README.md for further reference. + flaky = True, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.cc b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.cc new file mode 100644 index 000000000..c4eae2b5e --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.cc @@ -0,0 +1,124 @@ +#include "rmw_isolation/rmw_isolation.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +#include "tools/cpp/runfiles/runfiles.h" + +#define LITERAL_STRINGIFY(x) #x +#define STRINGIFY(x) LITERAL_STRINGIFY(x) + +using bazel::tools::cpp::runfiles::Runfiles; + +namespace ros2 { + +namespace { + +// Non-copyable, non-movable wrapper to bound an object's lifetime +// to local scope. Useful for objects that do not follow RAII, such +// as heap-allocated libc objects. +template +class scoped_instance { + public: + scoped_instance(ValueT&& value, DeleterT&& deleter) + : value_(value), deleter_(deleter) {} + scoped_instance(const scoped_instance&) = delete; + scoped_instance(scoped_instance&&) = delete; + scoped_instance& operator=(const scoped_instance&) = delete; + scoped_instance& operator=(scoped_instance&&) = delete; + + ~scoped_instance() { deleter_(std::move(value_)); } + + ValueT& get() { return value_; } + + private: + ValueT value_; + DeleterT deleter_; +}; + +// Helper function to enable type deduction on scoped_instance construction. +template +auto make_scoped_instance(ValueT&& value, DeleterT&& deleter) { + return scoped_instance(std::forward(value), + std::forward(deleter)); +} + +} // namespace + +void isolate_rmw_by_path(const std::string& argv0, const std::string& path) { + if (rclcpp::ok()) { + throw std::runtime_error( + "middleware already initialized, too late for isolation"); + } + std::string error; + std::unique_ptr runfiles(Runfiles::Create(argv0, &error)); + if (!runfiles) { + throw std::runtime_error(error); + } + const std::string generate_isolated_env_rmw_location = runfiles->Rlocation( + STRINGIFY(RMW_ISOLATION_ROOTPATH) "/generate_isolated_rmw_env"); + const std::string scratch_directory_template = path + "/XXXXXX"; + auto mutable_scratch_directory_template = make_scoped_instance( + strdup(scratch_directory_template.c_str()), free); + if (mkdtemp(mutable_scratch_directory_template.get()) == nullptr) { + throw std::system_error(errno, std::system_category(), strerror(errno)); + } + std::ostringstream command_line; + command_line << generate_isolated_env_rmw_location << " --scratch-directory " + << mutable_scratch_directory_template.get() + << " --rmw-implementation " + << rmw_get_implementation_identifier() << " " << path; + std::string command = command_line.str(); + fflush(stdout); // Flush stdout to avoid interactions with forked process + auto command_stream = make_scoped_instance( + popen(command.c_str(), "r"), [&](FILE* command_stream) { + if (command_stream != nullptr) { + int return_code = pclose(command_stream); + if (return_code == -1 && errno == ECHILD) { + throw std::system_error(errno, std::system_category(), + strerror(errno)); + } + if (return_code != 0) { + std::ostringstream error_message; + error_message << generate_isolated_env_rmw_location + << " exited with " + return_code; + throw std::runtime_error(error_message.str()); + } + } + }); + if (command_stream.get() == nullptr) { + throw std::system_error(errno, std::system_category(), strerror(errno)); + } + size_t length = 0; + auto buffer = make_scoped_instance(nullptr, free); + while (getline(&buffer.get(), &length, command_stream.get()) != -1) { + char* bufferp = buffer.get(); // let strsep() mutate bufferp but not buffer + const std::string line{strsep(&bufferp, "\r\n")}; // drop trailing newline + const size_t separator_index = line.find("="); + const std::string name = line.substr(0, separator_index); + if (name.empty()) { + throw std::runtime_error("internal error: invalid environment variable"); + } + const std::string value = separator_index != std::string::npos + ? line.substr(separator_index + 1) + : ""; + if (setenv(name.c_str(), value.c_str(), 1) != 0) { + throw std::system_error(errno, std::system_category(), strerror(errno)); + } + } + if (!feof(command_stream.get())) { + throw std::system_error(errno, std::system_category(), strerror(errno)); + } +} + +} // namespace ros2 diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.h b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.h new file mode 100644 index 000000000..0915d3302 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.h @@ -0,0 +1,21 @@ +#pragma once + +#include + +namespace ros2 { + +/// Isolates rmw implementation network traffic. +/** + * This function relies on the `generate_isolated_rmw_env` CLI to + * populate the calling process environment and achieve network + * isolation. + * + * \param argv0 program name to help locate `generate_isolated_rmw_env` + * \param path unique path to use as a basis for isolation. + * \throws std::runtime_error if called after rmw initialization. + * \throws std::runtime_error if `isolated_rmw_env` exits abnormally. + * \throws std::system_error if a system call fails. + */ +void isolate_rmw_by_path(const std::string& argv0, const std::string& path); + +} // namespace ros2 diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.py b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.py new file mode 100644 index 000000000..4e12ac7dd --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/rmw_isolation.py @@ -0,0 +1,247 @@ +import hashlib +import os +import pathlib +import tempfile + +import xml.dom.minidom as minidom +import xml.etree.ElementTree as ET + +import rclpy + +PARTICIPANT_ID_GAIN = 2 +MAX_PARTICIPANTS_PER_PROCESS = 10 + +MULTICAST_PORTS_INTERVAL = 2 +UNICAST_PORTS_INTERVAL = MAX_PARTICIPANTS_PER_PROCESS * PARTICIPANT_ID_GAIN + + +def generate_isolated_rmw_fastrtps_cpp_env( + unique_identifier, scratch_directory +): + """ + Generates an environment that forces rmw_fastrtps_cpp + network traffic isolation. + + This function achieves network traffic isolation by modifying multicast + discovery IPv4 address and well-known ports offsets, which FastRTPS (now + FastDDS) allows through the FASTRTPS_DEFAULT_PROFILES_FILE environment + variable. For further reference, see + https://fast-dds.docs.eprosima.com/en/latest/fastdds/transport/listening_locators.html. + + :param unique_identifier: unique arbitrary string to be used as + a basis for isolation. + :param scratch_directory: output directory for generated files. + :returns: a dictionary of environment variables. + """ + profile_name = unique_identifier + transport_name = unique_identifier + '/transport' + + digest = hashlib.sha256(unique_identifier.encode('utf8')).digest() + + builder = ET.TreeBuilder() + xmlns = 'http://www.eprosima.com/XMLSchemas/fastRTPS_Profiles' + builder.start('profiles', {'xmlns': xmlns}) + builder.start('transport_descriptors') + builder.start('transport_descriptor') + builder.start('transport_id') + builder.data(transport_name) + builder.end('transport_id') + builder.start('type') + builder.data('UDPv4') + builder.end('type') + builder.end('transport_descriptor') + builder.end('transport_descriptors') + builder.start('participant', { + 'profile_name': profile_name, + 'is_default_profile': 'true' + }) + builder.start('rtps') + builder.start('builtin') + builder.start('metatrafficMulticastLocatorList') + builder.start('locator') + builder.start('udpv4') + builder.start('address') + multicast_discovery_ip_address = '.'.join( + map(str, [239] + [int(c) for c in digest[:3]])) + builder.data(multicast_discovery_ip_address) + builder.end('address') + builder.end('udpv4') + builder.end('locator') + builder.end('metatrafficMulticastLocatorList') + builder.end('builtin') + builder.start('port') + builder.start('domainIDGain') + builder.data('0') # ignore domain IDs + builder.end('domainIDGain') + builder.start('participantIDGain') + builder.data(str(PARTICIPANT_ID_GAIN)) + builder.end('participantIDGain') + unicast_ports_offset = ( + ((digest[3] << 8) + digest[4]) % 2**10 # Use 10 bits + ) * UNICAST_PORTS_INTERVAL + multicast_ports_offset = ( + ((digest[5] << 8) + digest[6]) % 2**14 # Use 14 bits + ) * MULTICAST_PORTS_INTERVAL + builder.start('offsetd0') + builder.data(str(multicast_ports_offset)) + builder.end('offsetd0') + builder.start('offsetd1') + builder.data(str(unicast_ports_offset)) + builder.end('offsetd1') + builder.start('offsetd2') + builder.data(str(multicast_ports_offset + 1)) + builder.end('offsetd2') + builder.start('offsetd3') + builder.data(str(unicast_ports_offset + 1)) + builder.end('offsetd3') + builder.end('port') + builder.start('userTransports') + builder.start('transport_id') + builder.data(transport_name) + builder.end('transport_id') + builder.end('userTransports') + builder.start('useBuiltinTransports') + builder.data('false') + builder.end('useBuiltinTransports') + builder.end('rtps') + builder.end('participant') + builder.end('profiles') + tree = builder.close() + inline_xml = ET.tostring(tree, encoding='utf-8') + dom = minidom.parseString(inline_xml) + pretty_xml = dom.toprettyxml(indent=' ' * 4, encoding='utf-8') + profiles_path = pathlib.Path(scratch_directory) / 'fastrtps_profiles.xml' + with profiles_path.open('wb') as f: + f.write(pretty_xml) + return { + 'ROS_DOMAIN_ID': '0', + 'FASTRTPS_DEFAULT_PROFILES_FILE': str(profiles_path.resolve())} + + +def generate_isolated_rmw_cyclonedds_cpp_env( + unique_identifier, scratch_directory +): + """ + Generates an environment that forces rmw_cyclonedds_cpp + network traffic isolation. + + This function achieves network traffic isolation by modifying multicast + discovery IPv4 address and domain ID, which CycloneDDS allows through the + CYCLONEDDS_URI environment variable. For further reference, see + https://github.com/eclipse-cyclonedds/cyclonedds/blob/master/docs/manual/config.rst. + + :param unique_identifier: unique arbitrary string to be used as + a basis for isolation. + :param scratch_directory: output directory for generated files. + :returns: a dictionary of environment variables. + """ + digest = hashlib.sha256(unique_identifier.encode('utf8')).digest() + + builder = ET.TreeBuilder() + xmlns = 'http://www.w3.org/2001/XMLSchema-instance' + schema_location = ( + 'https://cdds.io/config https://raw.githubusercontent.com' + '/eclipse-cyclonedds/cyclonedds/master/etc/cyclonedds.xsd') + builder.start('CycloneDDS', { + 'xmlns:xsi': xmlns, 'xsi:schemaLocation': schema_location}) + builder.start('Domain', {'id': 'any'}) + builder.start('Discovery') + builder.start('SPDPMulticastAddress') + multicast_discovery_ip_address = '.'.join( + map(str, [239] + [int(c) for c in digest[:3]])) + builder.data(multicast_discovery_ip_address) + builder.end('SPDPMulticastAddress') + builder.start('ParticipantIndex') + builder.data('none') # disable unicast discovery + builder.end('ParticipantIndex') + builder.start('Ports') + builder.start('DomainGain') + builder.data('0') # ignore domain IDs + builder.end('DomainGain') + builder.start('ParticipantGain') + builder.data(str(PARTICIPANT_ID_GAIN)) + builder.end('ParticipantGain') + unicast_ports_offset = ( + ((digest[3] << 8) + digest[4]) % 2**10 # Use 10 bits + ) * UNICAST_PORTS_INTERVAL + multicast_ports_offset = ( + ((digest[5] << 8) + digest[6]) % 2**14 # Use 14 bits + ) * MULTICAST_PORTS_INTERVAL + builder.start('MulticastMetaOffset') + builder.data(str(multicast_ports_offset)) + builder.end('MulticastMetaOffset') + builder.start('UnicastMetaOffset') + builder.data(str(unicast_ports_offset)) + builder.end('UnicastMetaOffset') + builder.start('MulticastDataOffset') + builder.data(str(multicast_ports_offset + 1)) + builder.end('MulticastDataOffset') + builder.start('UnicastDataOffset') + builder.data(str(unicast_ports_offset + 1)) + builder.end('UnicastDataOffset') + builder.end('Ports') + builder.end('Discovery') + builder.end('Domain') + builder.end('CycloneDDS') + tree = builder.close() + inline_xml = ET.tostring(tree, encoding='utf-8') + dom = minidom.parseString(inline_xml) + pretty_xml = dom.toprettyxml(indent=' ' * 4, encoding='utf-8') + configuration_path = pathlib.Path( + scratch_directory) / 'cyclonedds_configuration.xml' + with configuration_path.open('wb') as f: + f.write(pretty_xml) + return { + 'ROS_DOMAIN_ID': '0', + 'CYCLONEDDS_URI': f'file://{configuration_path.resolve()}'} + + +_RMW_ISOLATION_FUNCTIONS = { + 'rmw_fastrtps_cpp': generate_isolated_rmw_fastrtps_cpp_env, + 'rmw_cyclonedds_cpp': generate_isolated_rmw_cyclonedds_cpp_env +} + + +def generate_isolated_rmw_env( + unique_identifier, rmw_implementation=None, scratch_directory=None +): + """ + Generates an environment that forces rmw implementation + network traffic isolation. + + :param unique_identifier: unique arbitrary string to be used as + a basis for isolation. + :param rmw_implementation: optional target rmw implementation. + If not provided, the currently applicable implementation + will be used, as returned by rclpy.get_rmw_implementation_identifier(). + :param scratch_directory: optional directory for generated files, if any. + If not provided, a temporary directory will be created. + :returns: a dictionary of environment variables. + :raises ValueError: if the target rmw implementation is unknown. + """ + if rmw_implementation is None: + rmw_implementation = rclpy.get_rmw_implementation_identifier() + if scratch_directory is None: + scratch_directory = tempfile.mkdtemp() + if rmw_implementation not in _RMW_ISOLATION_FUNCTIONS: + raise ValueError( + f"cannot isolate unknown '{rmw_implementation}' implementation") + return _RMW_ISOLATION_FUNCTIONS[rmw_implementation]( + unique_identifier, scratch_directory=scratch_directory) + + +def isolate_rmw_by_path(path): + """ + Isolates rmw implementation network traffic. + + This function relies on `isolated_rmw_env` to populate + the calling process environment and achieve network isolation. + + :param path: unique path to use as a basis for isolation. + :raises RuntimeError: if called after rmw initialization. + """ + if rclpy.ok(): + raise RuntimeError( + 'middleware already initialized, too late for isolation') + os.environ.update(generate_isolated_rmw_env( + str(path), scratch_directory=tempfile.mkdtemp(dir=str(path)))) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.cc b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.cc new file mode 100644 index 000000000..6cbe22e1a --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.cc @@ -0,0 +1,55 @@ +#include +#include + +#include +#include + +#include "rmw_isolation/rmw_isolation.h" + +using std::placeholders::_1; + +class IsolatedListener : public rclcpp::Node { + public: + IsolatedListener(const std::string& uuid) + : rclcpp::Node("isolated_listener"), uuid_(uuid) { + double timeout = this->declare_parameter("timeout", 2.0); + subscription_ = this->create_subscription( + "uuid", 10, std::bind(&IsolatedListener::topic_callback, this, _1)); + timer_ = this->create_wall_timer( + std::chrono::duration(timeout), + std::bind(&IsolatedListener::timer_callback, this)); + } + + private: + void topic_callback(const std_msgs::msg::String::SharedPtr msg) { + if (msg->data != uuid_) { + throw std::runtime_error("I heard '" + msg->data + "' yet " + + "I was expecting '" + uuid_ + "'!"); + } + ++expected_messages_count_; + } + + void timer_callback() const { + if (0u == expected_messages_count_) { + throw std::runtime_error("I did not hear '" + uuid_ + "' even once!"); + } + rclcpp::shutdown(); + } + + rclcpp::Subscription::SharedPtr subscription_; + rclcpp::TimerBase::SharedPtr timer_; + size_t expected_messages_count_{0u}; + std::string uuid_; +}; + +int main(int argc, char* argv[]) { + const char* TEST_TMPDIR = std::getenv("TEST_TMPDIR"); + if (TEST_TMPDIR != nullptr) { + ros2::isolate_rmw_by_path(argv[0], TEST_TMPDIR); + } + rclcpp::init(argc, argv); + std::string uuid{TEST_TMPDIR != nullptr ? TEST_TMPDIR : "none"}; + rclcpp::spin(std::make_shared(uuid)); + rclcpp::shutdown(); + return 0; +} diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.py b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.py new file mode 100644 index 000000000..91aa703af --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_listener.py @@ -0,0 +1,64 @@ +import os + +import rclpy +import rclpy.executors +import rclpy.node +import std_msgs.msg + + +class IsolatedListener(rclpy.node.Node): + + def __init__(self, uuid): + super().__init__('isolated_listener') + self._subscription = self.create_subscription( + std_msgs.msg.String, + 'uuid', + self._topic_callback, + 10) + timeout = self.declare_parameter('timeout', 2.0) + self._timer = self.create_timer( + timeout.value, self._timer_callback) + self._expected_messages_received = 0 + self._uuid = uuid + + def _topic_callback(self, msg): + assert msg.data == self._uuid, \ + f"I heard '{msg.data}' yet I was expecting '{self._uuid}'!" + self._expected_messages_received += 1 + + def _timer_callback(self): + assert self._expected_messages_received > 0, \ + f"I did not hear '{elf._uuid}' even once!" + rclpy.shutdown() + + +def main(): + if 'TEST_TMPDIR' in os.environ: + from rmw_isolation import isolate_rmw_by_path + isolate_rmw_by_path(os.environ['TEST_TMPDIR']) + + rclpy.init() + uuid = os.environ.get('TEST_TMPDIR', 'none') + try: + executor = rclpy.executors.SingleThreadedExecutor() + rclpy.spin(IsolatedListener(uuid), executor) + # TODO(hidmic): simplify `except` blocks as follows: + # + # except KeyboardInterrupt: + # pass + # finally: + # rclpy.try_shutdown() + # + # when rclpy 3.2.0 is rolled out (or any rclpy version + # including https://github.com/ros2/rclpy/pull/868 is). + except rclpy.executors.ExternalShutdownException: + pass + except KeyboardInterrupt: + rclpy.shutdown() + except Exception: + rclpy.shutdown() + raise + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.cc b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.cc new file mode 100644 index 000000000..a94c75ef9 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.cc @@ -0,0 +1,41 @@ +#include +#include + +#include +#include + +#include "rmw_isolation/rmw_isolation.h" + +class IsolatedTalker : public rclcpp::Node { + public: + IsolatedTalker(const std::string& uuid) + : rclcpp::Node("isolated_talker"), uuid_(uuid) { + publisher_ = this->create_publisher("uuid", 10); + timer_ = this->create_wall_timer( + std::chrono::duration(0.1), + std::bind(&IsolatedTalker::timer_callback, this)); + } + + private: + void timer_callback() const { + std_msgs::msg::String message; + message.data = uuid_; + publisher_->publish(message); + } + + rclcpp::Publisher::SharedPtr publisher_; + rclcpp::TimerBase::SharedPtr timer_; + std::string uuid_; +}; + +int main(int argc, char* argv[]) { + const char* TEST_TMPDIR = std::getenv("TEST_TMPDIR"); + if (TEST_TMPDIR != nullptr) { + ros2::isolate_rmw_by_path(argv[0], TEST_TMPDIR); + } + rclcpp::init(argc, argv); + std::string uuid{TEST_TMPDIR != nullptr ? TEST_TMPDIR : "none"}; + rclcpp::spin(std::make_shared(uuid)); + rclcpp::shutdown(); + return 0; +} diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.py b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.py new file mode 100644 index 000000000..fc3622a0e --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/isolated_talker.py @@ -0,0 +1,52 @@ +import os + +import rclpy +import rclpy.node +import std_msgs.msg + + +class IsolatedTalker(rclpy.node.Node): + + def __init__(self, uuid): + super().__init__('isolated_talker') + self._publisher = self.create_publisher( + std_msgs.msg.String, 'uuid', 10) + self._timer = self.create_timer(0.1, self._timer_callback) + self._uuid = uuid + + def _timer_callback(self): + msg = std_msgs.msg.String() + msg.data = self._uuid + self._publisher.publish(msg) + + +def main(): + if 'TEST_TMPDIR' in os.environ: + from rmw_isolation import isolate_rmw_by_path + isolate_rmw_by_path(os.environ['TEST_TMPDIR']) + + rclpy.init() + + uuid = os.environ.get('TEST_TMPDIR', 'none') + try: + rclpy.spin(IsolatedTalker(uuid)) + # TODO(hidmic): simplify `except` blocks as follows: + # + # except KeyboardInterrupt: + # pass + # finally: + # rclpy.try_shutdown() + # + # when rclpy 3.2.0 is rolled out (or any rclpy version + # including https://github.com/ros2/rclpy/pull/868 is). + except rclpy.executors.ExternalShutdownException: + pass + except KeyboardInterrupt: + rclpy.shutdown() + except Exception: + rclpy.shutdown() + raise + + +if __name__ == '__main__': + main() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/rmw_isolation_test.sh b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/rmw_isolation_test.sh new file mode 100755 index 000000000..a587aae06 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rmw_isolation/test/rmw_isolation_test.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +export TALKER_EXECUTABLE=$1 +export LISTENER_EXECUTABLE=$2 +export TIMEOUT=2.0 # in seconds + +# Avoid issues when trying to expand '~' +export DEFAULT_ROS_ARGS="--disable-external-lib-logs" + +function test_isolation +{ + if [ ! -z "${TEST_TMPDIR}" ]; then + if [ ! -z "$1" ]; then + # Handle concurrent subtests in TEST_TMPDIR + export TEST_TMPDIR="${TEST_TMPDIR}/$1" + mkdir -p "${TEST_TMPDIR}" + fi + fi + + # Start talker in the background + TALKER_ROS_ARGS=$DEFAULT_ROS_ARGS + $TALKER_EXECUTABLE --ros-args $TALKER_ROS_ARGS -- & + TALKER_PID=$! + + sleep 1.0 # Let the talker start up + + # Run listener in the foreground + LISTENER_ROS_ARGS="$DEFAULT_ROS_ARGS --param timeout:=$TIMEOUT" + $LISTENER_EXECUTABLE --ros-args $LISTENER_ROS_ARGS + return_code=$? # Keep listener return code + + # Kill talker in the background + if ! kill $TALKER_PID; then + # Talker exited too early, likely with an error + wait $TALKER_PID + return_code=$? # Keep talker return code + fi + + return $return_code +} +export -f test_isolation # Export to subshells + +# Run as many concurrent tests as shards are available +[ -z "${TEST_SHARD_STATUS_FILE-}" ] || touch "$TEST_SHARD_STATUS_FILE" + +# Run as many SUBTESTS_PER_TEST as requested, across WORKER_COUNT processes +SUBTESTS_PER_TEST=${SUBTESTS_PER_TEST:-10} +WORKER_COUNT=${WORKER_COUNT:-$(nproc --all)} + +seq 0 ${SUBTESTS_PER_TEST} | xargs -n 1 -P ${WORKER_COUNT} -I{} bash -c "test_isolation subtest_{}_of_${SUBTESTS_PER_TEST}" diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_cc.bzl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_cc.bzl new file mode 100644 index 000000000..88d5e9bc3 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_cc.bzl @@ -0,0 +1,128 @@ +# -*- python -*- + +load( + "@drake_ros//tools/skylark:dload_cc.bzl", + "dload_cc_shim" +) +load( + "@drake_ros//tools/skylark:kwargs.bzl", + "filter_to_only_common_kwargs", + "remove_test_specific_kwargs" +) +load( + ":common.bzl", + "incorporate_rmw_implementation", +) +load( + ":distro.bzl", + "RUNTIME_ENVIRONMENT" +) + +def ros_cc_binary( + name, rmw_implementation = None, cc_binary_rule = native.cc_binary, **kwargs +): + """ + Builds a C/C++ binary and wraps it with a shim that will inject the minimal + runtime environment necessary for execution when depending on targets from + this ROS 2 local repository. + + Equivalent to the cc_binary() rule, which this rule decorates. + + Args: + name: C/C++ binary target name + rmw_implementation: optional RMW implementation to run against + cc_binary_rule: optional cc_binary() rule override + + Additional keyword arguments are forwarded to the `cc_binary_rule`. + """ + if kwargs.get("linkshared", False): + native.cc_binary(name = name, **kwargs) + return + + binary_name = "_" + name + "_shimmed" + binary_kwargs = kwargs + binary_env_changes = dict(RUNTIME_ENVIRONMENT) + + if rmw_implementation: + binary_kwargs, binary_env_changes = \ + incorporate_rmw_implementation( + binary_kwargs, binary_env_changes, + rmw_implementation = rmw_implementation + ) + + cc_binary_rule( + name = binary_name, + **binary_kwargs + ) + + shim_name = "_" + name + "_shim.cc" + shim_kwargs = filter_to_only_common_kwargs(kwargs) + dload_cc_shim( + name = shim_name, + target = ":" + binary_name, + env_changes = binary_env_changes, + **shim_kwargs + ) + + kwargs.update( + srcs = [shim_name], + data = [":" + binary_name], + deps = ["@bazel_tools//tools/cpp/runfiles"], + tags = ["nolint"] + kwargs.get("tags", []) + ) + cc_binary_rule(name = name, **kwargs) + +def ros_cc_test( + name, rmw_implementation = None, + cc_binary_rule = native.cc_binary, + cc_test_rule = native.cc_test, **kwargs +): + """ + Builds a C/C++ test and wraps it with a shim that will inject the minimal + runtime environment necessary for execution when depending on targets from + this ROS 2 local repository. + + Equivalent to the cc_test() rule. + + Args: + name: C/C++ test target name + rmw_implementation: optional RMW implementation to run against + cc_binary_rule: optional cc_binary() rule override. + cc_test_rule: optional cc_test() rule override + + Additional keyword arguments are forwarded to the `cc_test_rule` and to the + `cc_binary_rule` (minus the test specific ones). + """ + binary_name = "_" + name + "_shimmed" + binary_kwargs = remove_test_specific_kwargs(kwargs) + binary_kwargs.update(testonly = True) + binary_env_changes = dict(RUNTIME_ENVIRONMENT) + if rmw_implementation: + binary_kwargs, test_env_changes = \ + incorporate_rmw_implementation( + binary_kwargs, binary_env_changes, + rmw_implementation = rmw_implementation + ) + + cc_binary_rule( + name = binary_name, + **binary_kwargs + ) + + shim_name = "_" + name + "_shim.cc" + shim_kwargs = filter_to_only_common_kwargs(kwargs) + shim_kwargs.update(testonly = True) + dload_cc_shim( + name = shim_name, + target = ":" + binary_name, + env_changes = binary_env_changes, + **shim_kwargs + ) + + kwargs.update( + srcs = [shim_name], + data = [":" + binary_name], + deps = ["@bazel_tools//tools/cpp/runfiles"], + tags = ["nolint"] + kwargs.get("tags", []) + ) + cc_test_rule(name = name, **kwargs) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_py.bzl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_py.bzl new file mode 100644 index 000000000..51e8d36ed --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/ros_py.bzl @@ -0,0 +1,179 @@ +# -*- python -*- + +load( + "@drake_ros//tools/skylark:dload_py.bzl", + "dload_py_shim" +) +load( + "@drake_ros//tools/skylark:kwargs.bzl", + "filter_to_only_common_kwargs", + "remove_test_specific_kwargs" +) +load( + ":common.bzl", + "incorporate_rmw_implementation", +) +load( + ":distro.bzl", + "RUNTIME_ENVIRONMENT" +) + +def ros_import_binary( + name, executable, rmw_implementation = None, py_binary_rule = native.py_binary, **kwargs +): + """ + Imports an existing executable by wrapping it with a Python shim that will inject the + minimal runtime environment necessary for execution when depending on this ROS 2 local + repository. Imported executables need not be Python -- binary executables will work the + same. + + Akin to the cc_import() rule. + + Args: + name: imported executable target name + executable: path to an executable file + rmw_implementation: optional RMW implementation to run against + py_binary_rule: optional py_binary() rule override + + Additional keyword arguments are forwarded to the `py_binary_rule`. + """ + + env_changes = dict(RUNTIME_ENVIRONMENT) + if rmw_implementation: + kwargs, env_changes = \ + incorporate_rmw_implementation( + kwargs, env_changes, + rmw_implementation = rmw_implementation + ) + + shim_name = "_" + name + "_shim.py" + shim_kwargs = filter_to_only_common_kwargs(kwargs) + dload_py_shim( + name = shim_name, + target = executable, + env_changes = env_changes, + **shim_kwargs + ) + + kwargs.update( + srcs = [shim_name], + main = shim_name, + tags = ["nolint"] + kwargs.get("tags", []), + data = [executable] + kwargs.get("data", []), + deps = kwargs.get("deps", []) + [ + "@bazel_tools//tools/python/runfiles"] + ) + py_binary_rule(name = name, **kwargs) + +def ros_py_binary( + name, rmw_implementation = None, py_binary_rule = native.py_binary, **kwargs +): + """ + Builds a Python binary and wraps it with a shim that will inject the minimal + runtime environment necessary for execution when depending on targets from + this ROS 2 local repository. + + Equivalent to the py_binary() rule, which this rule decorates. + + Args: + name: Python binary target name + rmw_implementation: optional RMW implementation to run against + py_binary_rule: optional py_binary() rule override + + Additional keyword arguments are forwarded to the `py_binary_rule`. + """ + + binary_name = "_" + name + "_shimmed" + binary_kwargs = dict(kwargs) + if "main" not in binary_kwargs: + binary_kwargs["main"] = name + ".py" + binary_env_changes = dict(RUNTIME_ENVIRONMENT) + + if rmw_implementation: + binary_kwargs, binary_env_changes = \ + incorporate_rmw_implementation( + binary_kwargs, binary_env_changes, + rmw_implementation = rmw_implementation + ) + + py_binary_rule( + name = binary_name, + **binary_kwargs + ) + + shim_name = "_" + name + "_shim.py" + shim_kwargs = filter_to_only_common_kwargs(kwargs) + dload_py_shim( + name = shim_name, + target = ":" + binary_name, + env_changes = binary_env_changes, + **shim_kwargs + ) + + kwargs.update( + srcs = [shim_name], + main = shim_name, + data = [":" + binary_name], + deps = [ + "@bazel_tools//tools/python/runfiles", + ":" + binary_name, # Support py_binary being used a dependency + ], + tags = ["nolint"] + kwargs.get("tags", []) + ) + py_binary_rule(name = name, **kwargs) + +def ros_py_test( + name, rmw_implementation = None, + py_binary_rule = native.py_binary, + py_test_rule = native.py_test, **kwargs +): + """ + Builds a Python test and wraps it with a shim that will inject the minimal + runtime environment necessary for execution when depending on targets from + this ROS 2 local repository. + + Equivalent to the py_test() rule, which this rule decorates. + + Args: + name: Python test target name + rmw_implementation: optional RMW implementation to run against + py_binary_rule: optional py_binary() rule override + py_test_rule: optional py_test() rule override + + Additional keyword arguments are forwarded to the `py_test_rule` and to the + `py_binary_rule` (minus the test specific ones). + """ + binary_name = "_" + name + "_shimmed" + binary_kwargs = remove_test_specific_kwargs(kwargs) + binary_kwargs.update(testonly = True) + binary_env_changes = dict(RUNTIME_ENVIRONMENT) + if rmw_implementation: + binary_kwargs, binary_env_changes = \ + incorporate_rmw_implementation( + binary_kwargs, binary_env_changes, + rmw_implementation = rmw_implementation, + ) + + py_binary_rule( + name = binary_name, + **binary_kwargs + ) + + shim_name = "_" + name + "_shim.py" + shim_kwargs = filter_to_only_common_kwargs(kwargs) + shim_kwargs.update(testonly = True) + dload_py_shim( + name = shim_name, + target = ":" + binary_name, + env_changes = binary_env_changes, + **shim_kwargs + ) + + kwargs.update( + srcs = [shim_name], + main = shim_name, + data = [":" + binary_name], + deps = ["@bazel_tools//tools/python/runfiles"], + tags = ["nolint"] + kwargs.get("tags", []) + ) + py_test_rule(name = name, **kwargs) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/rosidl.bzl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rosidl.bzl new file mode 100644 index 000000000..0c3957db7 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/rosidl.bzl @@ -0,0 +1,1067 @@ +# -*- python -*- + +load("@python_dev//:version.bzl", "PYTHON_EXTENSION_SUFFIX") +load( + ":distro.bzl", + "AVAILABLE_TYPESUPPORT_LIST", + "REPOSITORY_ROOT" +) + +def _as_idl_tuple(file): + """ + Returns IDL tuple for a file as expected by `rosidl` commands. + """ + path, parent, base = file.path.rsplit("/", 2) + if parent not in ("msg", "srv", "action"): + fail("Interface parent folder must be one of: 'msg', 'srv', 'action'") + return "{}:{}/{}".format(path, parent, base) + +def _as_include_flag(file): + """ + Returns path to file as an include flag for `rosidl` commands. + """ + return "-I" + file.path.rsplit("/", 3)[0] + +def _rosidl_generate_genrule_impl(ctx): + args = ctx.actions.args() + args.add("generate") + output_path_parts = [ + ctx.var["GENDIR"], ctx.label.workspace_root, + ctx.label.package, ctx.attr.output_dir] + output_path = "/".join([ + part for part in output_path_parts if part]) + args.add("--output-path", output_path) + for type in ctx.attr.types: + args.add("--type", type) + for typesupport in ctx.attr.typesupports: + args.add("--type-support", typesupport) + args.add_all(ctx.files.includes, map_each=_as_include_flag, uniquify=True) + args.add(ctx.attr.group) + args.add_all(ctx.files.interfaces, map_each=_as_idl_tuple) + inputs = ctx.files.interfaces + ctx.files.includes + ctx.actions.run_shell( + tools = [ctx.executable._tool], + arguments = [args], inputs = inputs, + command = "{} $@ > /dev/null".format( + ctx.executable._tool.path + ), + outputs = ctx.outputs.generated_sources, + ) + +rosidl_generate_genrule = rule( + attrs = dict( + generated_sources = attr.output_list(mandatory = True), + types = attr.string_list(mandatory = False), + typesupports = attr.string_list(mandatory = False), + group = attr.string(mandatory = True), + interfaces = attr.label_list( + mandatory = True, allow_files = True + ), + includes = attr.label_list(mandatory = False), + output_dir = attr.string(mandatory = False), + _tool = attr.label( + default = REPOSITORY_ROOT + ":rosidl", + executable = True, cfg = "exec" + ) + ), + implementation = _rosidl_generate_genrule_impl, + output_to_genfiles = True, +) +""" +Generates ROS 2 interface type representation and/or type support sources. + +Args: + generated_sources: expected sources after generation. + types: list of type representations to generate (e.g. cpp, py). + typesupports: list of type supports to generate (e.g. typesupport_cpp). + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files, both files and filegroups are allowed. + includes: optional interface definition includes, both files and filegroups are allowed. + output_dir: optional output subdirectory. + +See `rosidl generate` CLI for further reference. +""" + +def _rosidl_translate_genrule_impl(ctx): + args = ctx.actions.args() + args.add("translate") + output_path_parts = [ + ctx.var["GENDIR"], ctx.label.workspace_root, + ctx.label.package, ctx.attr.output_dir] + output_path = "/".join([ + part for part in output_path_parts if part]) + args.add("--output-path", output_path) + args.add("--output-format", ctx.attr.output_format) + if ctx.attr.input_format: + args.add("--input-format", ctx.attr.input_format) + args.add_all(ctx.files.includes, map_each=_as_include_flag, uniquify=True) + args.add(ctx.attr.group) + args.add_all(ctx.files.interfaces, map_each=_as_idl_tuple) + inputs = ctx.files.interfaces + ctx.files.includes + ctx.actions.run_shell( + tools = [ctx.executable._tool], + arguments = [args], inputs = inputs, + command = "{} $@ > /dev/null".format( + ctx.executable._tool.path + ), + outputs = ctx.outputs.translated_interfaces + ) + +rosidl_translate_genrule = rule( + attrs = dict( + translated_interfaces = attr.output_list(mandatory = True), + output_format = attr.string(mandatory = True), + input_format = attr.string(mandatory = False), + group = attr.string(mandatory = True), + interfaces = attr.label_list( + mandatory = True, allow_files = True + ), + includes = attr.label_list(mandatory = False), + output_dir = attr.string(mandatory = False), + _tool = attr.label( + default = REPOSITORY_ROOT + ":rosidl", + executable = True, cfg = "exec" + ) + ), + implementation = _rosidl_translate_genrule_impl, + output_to_genfiles = True, +) +""" +Translates ROS 2 interface definition files. + +Args: + translated_interfaces: execpted interface definition files after translation. + output_format: output format to translate interface definition files to. + input_format: optional input format, deduced from file extensions by default. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files, both files and filegroups are allowed. + includes: optional interface definition includes, both files and filegroups are allowed. + output_dir: optional output subdirectory. + +See `rosidl translate` CLI for further reference. +""" + +def _deduce_source_parts(interface_path): + """ + Deduces source subdirectory and basename for a given path to an interface definition file. + """ + parent, _, base = interface_path.rpartition("/") + basename, _, ext = base.rpartition(".") + basename = basename[0].lower() + "".join([ + "_" + char.lower() if char.isupper() else char + for char in basename[1:].elems() + ]) + return parent, basename + +def rosidl_definitions_filegroup(name, group, interfaces, includes, **kwargs): + """ + Generates ROS 2 interfaces .idl definitions. + + This rule standardizes all interface definitions' format to IDL. + + Args: + name: filegroup target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files, both files and filegroups are allowed + includes: optional interface definition includes, both files and filegroups are allowed + + Additional keyword arguments are those common to all rules. + """ + translated_interfaces = [] + for ifc in interfaces: + base, _, ext = ifc.rpartition(".") + translated_interfaces.append(base + ".idl") + rosidl_translate_genrule( + name = name, + output_format = "idl", + translated_interfaces = translated_interfaces, + group = group, + interfaces = interfaces, + includes = includes, + **kwargs + ) + +def _deduce_source_paths(group, kind): + """ + Deduces include and root paths for generated sources of a given group and kind. + """ + include = "{}/{}".format(group, kind) + root = "{}/{}".format(include, group) + return include, root + +def _make_public_name(name, suffix=""): + """ + Builds a public name (i.e. with no leading underscore) from a + private or public name. + """ + return name.lstrip("_") + suffix + +def _make_private_name(name, suffix=""): + """ + Builds a private name (i.e. with leading underscore) from a + private or public name. + """ + if not name.startswith("_"): + name = "_" + name + return name + suffix + +def _make_public_label(label, suffix=""): + """ + Builds a public label (i.e. name with no leading underscore) + from a private or public label, or a plain name (assumed to + be local to the calling package). + """ + package, _, name = label.rpartition(":") + prefix, _, name = name.rpartition("/") + if prefix: + prefix = prefix + "/" + return package + ":" + prefix + _make_public_name(name, suffix) + +def _make_private_label(label, suffix=""): + """ + Builds a private label (i.e. name with leading underscore) + from a private or public label, or a plain name (assumed to + be local to the calling package). + """ + package, _, name = label.rpartition(":") + prefix, _, name = name.rpartition("/") + if prefix: + prefix = prefix + "/" + return package + ":" + prefix + _make_private_name(name, suffix) + +def rosidl_c_library( + name, group, interfaces, includes = [], deps = [], + cc_library_rule = native.cc_library, **kwargs +): + """ + Generates and builds C ROS 2 interfaces. + + Args: + name: C library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files, only files are allowed + includes: optional interface definition includes, both files and filegroups are allowed. + deps: optional library dependencies. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "c") + + generated_c_sources = [] + visibility_header = "msg/rosidl_generator_c__visibility_control.h" + generated_c_headers = ["{}/{}".format(root, visibility_header)] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_c_headers.append("{}/{}/{}.h".format(root, parent, basename)) + generated_c_headers.append("{}/{}/detail/{}__functions.h".format(root, parent, basename)) + generated_c_headers.append("{}/{}/detail/{}__struct.h".format(root, parent, basename)) + generated_c_headers.append("{}/{}/detail/{}__type_support.h".format(root, parent, basename)) + generated_c_sources.append("{}/{}/detail/{}__functions.c".format(root, parent, basename)) + generated_sources = generated_c_sources + generated_c_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + types = ["c"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + deps = deps + [ + REPOSITORY_ROOT + ":rcutils_cc", + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + ] + + cc_library_rule( + name = name, + srcs = generated_c_sources, + hdrs = generated_c_headers, + includes = [include], + deps = deps, + **kwargs + ) + +def rosidl_cc_library( + name, group, interfaces, includes = [], deps = [], + cc_library_rule = native.cc_library, **kwargs +): + """ + Generates and builds C++ ROS 2 interfaces. + + Args: + name: C++ library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + includes: optional interface definition includes. + deps: optional library dependencies. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "cpp") + + generated_cc_headers = [] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_cc_headers.append("{}/{}/{}.hpp".format(root, parent, basename)) + generated_cc_headers.append("{}/{}/detail/{}__builder.hpp".format(root, parent, basename)) + generated_cc_headers.append("{}/{}/detail/{}__struct.hpp".format(root, parent, basename)) + generated_cc_headers.append("{}/{}/detail/{}__traits.hpp".format(root, parent, basename)) + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_cc_headers, + types = ["cpp"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = name, + hdrs = generated_cc_headers, + includes = [include], + deps = deps + [ + REPOSITORY_ROOT + ":rosidl_runtime_cpp_cc" + ], + **kwargs + ) + +def _make_c_typesupport_extension_name(group, typesupport_name): + return "{}_s__{}".format(group, typesupport_name) + PYTHON_EXTENSION_SUFFIX + +def rosidl_py_library( + name, group, interfaces, typesupports, + includes = [], c_deps = [], py_deps = [], + cc_binary_rule = native.cc_binary, + cc_library_rule = native.cc_library, + py_library_rule = native.py_library, + **kwargs +): + """ + Generates and builds Python ROS 2 interfaces, including any C extensions. + + Args: + name: Python library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + typesupports: a mapping of available, vendor-specific + C typesupport libraries, from typesupport name to + library target label. + includes: optional interface definition includes. + c_deps: optional Python C extension dependencies. + py_deps: optional Python dependencies. + cc_binary_rule: optional cc_binary() rule override. + cc_library_rule: optional cc_library() rule override. + py_library_rule: optional py_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + import_, root = _deduce_source_paths(group, "py") + + generated_c_sources = [] + generated_py_sources = [] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + py_source = "{}/{}/__init__.py".format(root, parent) + if py_source not in generated_py_sources: + generated_py_sources.append(py_source) + generated_py_sources.append( + "{}/{}/_{}.py".format(root, parent, basename)) + generated_c_sources.append( + "{}/{}/_{}_s.c".format(root, parent, basename)) + generated_sources = generated_c_sources + generated_py_sources + + generated_c_sources_per_typesupport = {} + for typesupport_name in typesupports: + generated_c_sources_per_typesupport[typesupport_name] = [ + "{}/_{}_s.ep.{}.c".format(root, group, typesupport_name)] + generated_sources += generated_c_sources_per_typesupport[typesupport_name] + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + types = [ + "py[typesupport_implementations:[{}]]" + .format(",".join(typesupports)) + ], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = _make_private_name(name, "_c"), + srcs = generated_c_sources, + deps = c_deps + [ + _make_private_label(dep, "_c") for dep in py_deps + ] + ["@python_dev//:libs"], + **kwargs + ) + + c_typesupport_extension_deps = c_deps + [ + _make_private_label(name, "_c"), + REPOSITORY_ROOT + ":rosidl_generator_py_cc", + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + "@python_dev//:libs", + ] + py_data = [] + for typesupport_name, typesupport_library in typesupports.items(): + deps = list(c_typesupport_extension_deps) + if typesupport_library not in deps: + deps.append(typesupport_library) + c_typesupport_extension = "{}/{}".format( + root, _make_c_typesupport_extension_name(group, typesupport_name)) + cc_binary_rule( + name = c_typesupport_extension, + srcs = generated_c_sources_per_typesupport[typesupport_name], + deps = deps, linkshared = True, **kwargs + ) + py_data.append(c_typesupport_extension) + + py_library_rule( + name = name, + srcs = generated_py_sources, + imports = [import_], + data = py_data, + deps = py_deps, + **kwargs + ) + +def rosidl_typesupport_fastrtps_cc_library( + name, group, interfaces, includes = [], deps = [], + cc_binary_rule = native.cc_binary, cc_library_rule = native.cc_library, + **kwargs +): + """ + Generates and builds FastRTPS C++ typesupport for ROS 2 interfaces. + + Args: + name: FastRTPS C++ typesupport library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + includes: optional interface definition includes. + deps: optional library dependencies. + cc_binary_rule: optional cc_binary() rule override. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/fastrtps_cpp") + + generated_cc_sources = [] + visibility_header = "msg/rosidl_typesupport_fastrtps_cpp__visibility_control.h" + generated_cc_headers = ["{}/{}".format(root, visibility_header)] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + template = "{}/{}/detail/dds_fastrtps/{}__type_support.cpp" + generated_cc_sources.append(template.format(root, parent, basename)) + template = "{}/{}/detail/{}__rosidl_typesupport_fastrtps_cpp.hpp" + generated_cc_headers.append(template.format(root, parent, basename)) + generated_sources = generated_cc_sources + generated_cc_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + typesupports = ["fastrtps_cpp"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = _make_private_name(name, "_hdrs"), + hdrs = generated_cc_headers, + includes = [include], + linkstatic = True, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_cc_sources, + deps = deps + [ + _make_private_label(name, "_hdrs"), + REPOSITORY_ROOT + ":fastcdr_cc", + REPOSITORY_ROOT + ":rmw_cc", + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_fastrtps_cpp_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + ], + linkshared = True, + **kwargs + ) + +def rosidl_typesupport_fastrtps_c_library( + name, group, interfaces, includes = [], deps = [], + cc_binary_rule = native.cc_binary, cc_library_rule = native.cc_library, + **kwargs +): + """ + Generates and builds FastRTPS C typesupport for ROS 2 interfaces. + + Args: + name: FastRTPS C typesupport library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + includes: optional interface definition includes. + deps: optional library dependencies. + cc_binary_rule: optional cc_binary() rule override. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/fastrtps_c") + + generated_c_sources = [] + visibility_header = "msg/rosidl_typesupport_fastrtps_c__visibility_control.h" + generated_c_headers = ["{}/{}".format(root, visibility_header)] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + template = "{}/{}/detail/{}__type_support_c.cpp" + generated_c_sources.append(template.format(root, parent, basename)) + template = "{}/{}/detail/{}__rosidl_typesupport_fastrtps_c.h" + generated_c_headers.append(template.format(root, parent, basename)) + generated_sources = generated_c_sources + generated_c_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + typesupports = ["fastrtps_c"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = _make_private_name(name, "_hdrs"), + hdrs = generated_c_headers, + includes = [include], + linkstatic = True, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_c_sources, + linkshared = True, + deps = deps + [ + _make_private_label(name, "_hdrs"), + REPOSITORY_ROOT + ":fastcdr_cc", + REPOSITORY_ROOT + ":rmw_cc", + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_fastrtps_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_fastrtps_cpp_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + ], + **kwargs + ) + +def rosidl_typesupport_introspection_c_library( + name, group, interfaces, includes = [], deps = [], + cc_binary_rule = native.cc_binary, cc_library_rule = native.cc_library, + **kwargs +): + """ + Generates and builds C introspection typesupport for ROS 2 interfaces. + + Args: + name: C introspection typesupport library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + includes: optional interface definition includes. + deps: optional library dependencies. + cc_binary_rule: optional cc_binary() rule override. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/introspection_c") + + generated_c_sources = [] + visibility_header = "msg/rosidl_typesupport_introspection_c__visibility_control.h" + generated_c_headers = ["{}/{}".format(root, visibility_header)] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_c_sources.append( + "{}/{}/detail/{}__type_support.c".format(root, parent, basename)) + template = "{}/{}/detail/{}__rosidl_typesupport_introspection_c.h" + generated_c_headers.append(template.format(root, parent, basename)) + generated_sources = generated_c_sources + generated_c_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + typesupports = ["introspection_c"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = _make_private_name(name, "_hdrs"), + hdrs = generated_c_headers, + includes = [include], + linkstatic = True, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_c_sources, + linkshared = True, + deps = deps + [ + _make_private_label(name, "_hdrs"), + REPOSITORY_ROOT + ":rosidl_typesupport_introspection_c_cc", + ], + **kwargs + ) + +def rosidl_typesupport_introspection_cc_library( + name, group, interfaces, includes = [], deps = [], + cc_binary_rule = native.cc_binary, cc_library_rule = native.cc_library, + **kwargs +): + """ + Generates and builds C++ introspection typesupport for ROS 2 interfaces. + + Args: + name: C++ introspection typesupport library target name. + group: interface group name (i.e. ROS 2 package name). + interfaces: interface definition files. + includes: optional interface definition includes. + deps: optional library dependencies. + cc_binary_rule: optional cc_binary() rule override. + cc_library_rule: optional cc_library() rule override. + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/introspection_cpp") + + generated_cc_sources = [] + generated_cc_headers = [] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_cc_sources.append( + "{}/{}/detail/{}__type_support.cpp".format(root, parent, basename)) + template = "{}/{}/detail/{}__rosidl_typesupport_introspection_cpp.hpp" + generated_cc_headers.append(template.format(root, parent, basename)) + generated_sources = generated_cc_sources + generated_cc_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + typesupports = ["introspection_cpp"], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_library_rule( + name = _make_private_name(name, "_hdrs"), + hdrs = generated_cc_headers, + includes = [include], + linkstatic = True, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_cc_sources, + linkshared = True, + deps = deps + [ + _make_private_label(name, "_hdrs"), + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_runtime_cpp_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_introspection_cpp_cc", + ], + **kwargs + ) + +def rosidl_typesupport_c_library( + name, group, interfaces, typesupports, includes = [], + deps = [], cc_binary_rule = native.cc_binary, **kwargs +): + """ + Generates and builds ROS 2 interfaces' C typesupport. + + Args: + name: C typesupport library target name + group: interface group name (i.e. ROS 2 package name) + interfaces: interface definition files + typesupports: a mapping of available, vendor-specific + C typesupport libraries, from typesupport name to + library target label. + includes: optional interface definition includes + deps: optional library dependencies + cc_binary_rule: optional cc_binary() rule override + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/c") + + generated_c_sources = [] + visibility_header = "msg/rosidl_typesupport_c__visibility_control.h" + generated_c_headers = ["{}/{}".format(root, visibility_header)] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_c_sources.append( + "{}/{}/{}__type_support.cpp".format(root, parent, basename)) + generated_sources = generated_c_sources + generated_c_headers + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_sources, + typesupports = [ + "c[typesupport_implementations:[{}]]" + .format(",".join(typesupports)) + ], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_sources, + includes = [include], + linkshared = True, + data = typesupports.values(), + deps = deps + [ + _make_private_label(label, "_hdrs") + for label in typesupports.values() + ] + [ + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_c_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + ], + **kwargs + ) + +def rosidl_typesupport_cc_library( + name, group, interfaces, typesupports, includes = [], + deps = [], cc_binary_rule = native.cc_binary, **kwargs +): + """ + Generates and builds ROS 2 interfaces' C++ typesupport. + + Args: + name: C++ typesupport library target name + group: interface group name (i.e. ROS 2 package name) + interfaces: interface definition files + typesupports: a mapping of available, vendor-specific + C++ typesupport libraries, from typesupport name to + library target label. + includes: optional interface definition includes + deps: optional library dependencies + cc_binary_rule: optional cc_binary() rule override + + Additional keyword arguments are those common to all rules. + """ + include, root = _deduce_source_paths(group, "typesupport/cpp") + + generated_cc_sources = [] + for ifc in interfaces: + parent, basename = _deduce_source_parts(ifc) + generated_cc_sources.append( + "{}/{}/{}__type_support.cpp".format(root, parent, basename)) + + rosidl_generate_genrule( + name = _make_private_name(name, "_gen"), + generated_sources = generated_cc_sources, + typesupports = [ + "cpp[typesupport_implementations:[{}]]" + .format(",".join(typesupports)) + ], + group = group, + interfaces = interfaces, + includes = includes, + output_dir = root, + **kwargs + ) + + cc_binary_rule( + name = name, + srcs = generated_cc_sources, + data = typesupports.values(), + includes = [include], + linkshared = True, + deps = deps + [ + _make_private_label(label, "_hdrs") + for label in typesupports.values() + ] + [ + REPOSITORY_ROOT + ":rosidl_runtime_c_cc", + REPOSITORY_ROOT + ":rosidl_runtime_cpp_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_cpp_cc", + REPOSITORY_ROOT + ":rosidl_typesupport_interface_cc", + ], + **kwargs + ) + +def rosidl_cc_support( + name, interfaces, deps, group = None, + cc_binary_rule = native.cc_binary, + cc_library_rule = native.cc_library, + **kwargs +): + """ + Generates and builds C++ ROS 2 interfaces. + + To depend on C++ interfaces, use the `_cc` target. + + Args: + name: interface group name, used as prefix for target names + interfaces: interface definition files + deps: optional interface group dependencies + group: optional interface group name override, useful when + target name cannot be forced to match the intended package + name for these interfaces + cc_binary_rule: optional cc_binary() rule override + cc_library_rule: optional cc_library() rule override + + Additional keyword arguments are those common to all rules. + """ + rosidl_cc_library( + name = _make_private_name(name, "__rosidl_cpp"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_public_label(dep, "_cc") for dep in deps], + cc_library_rule = cc_library_rule, + **kwargs + ) + + typesupports = {} + # NOTE: typesupport binary files must not have any leading + # underscores or C++ generated code will not be able to + # dlopen it. + if "rosidl_typesupport_introspection_cpp" in AVAILABLE_TYPESUPPORT_LIST: + rosidl_typesupport_introspection_cc_library( + name = _make_public_name(name, "__rosidl_typesupport_introspection_cpp"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_cpp")] + [ + _make_public_label(dep, "_cc") for dep in deps], + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + **kwargs + ) + typesupports["rosidl_typesupport_introspection_cpp"] = \ + _make_public_label(name, "__rosidl_typesupport_introspection_cpp") + + if "rosidl_typesupport_fastrtps_cpp" in AVAILABLE_TYPESUPPORT_LIST: + rosidl_typesupport_fastrtps_cc_library( + name = _make_public_name(name, "__rosidl_typesupport_fastrtps_cpp"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_cpp")] + [ + _make_public_label(dep, "_cc") for dep in deps], + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + **kwargs + ) + typesupports["rosidl_typesupport_fastrtps_cpp"] = \ + _make_public_label(name, "__rosidl_typesupport_fastrtps_cpp") + + rosidl_typesupport_cc_library( + name = _make_public_name(name, "__rosidl_typesupport_cpp"), + typesupports = typesupports, + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_cpp")] + [ + _make_public_label(dep, "_cc") for dep in deps], + cc_binary_rule = cc_binary_rule, + **kwargs + ) + + cc_library_rule( + name = _make_public_name(name, "_cc"), + srcs = [ + _make_public_label(name, "__rosidl_typesupport_cpp") + ] + typesupports.values(), + deps = [_make_private_label(name, "__rosidl_cpp")], + linkstatic = True, + **kwargs + ) + +def rosidl_py_support( + name, interfaces, deps, group = None, + cc_binary_rule = native.cc_binary, + cc_library_rule = native.cc_library, + py_library_rule = native.py_library, + **kwargs +): + """ + Generates and builds Python ROS 2 interfaces. + + To depend on Python interfaces, use the `_py` target. + + Args: + name: interface group name, used as prefix for target names + interfaces: interface definition files + deps: optional interface group dependencies + group: optional interface group name override, useful when + target name cannot be forced to match the intended package + name for these interfaces + cc_binary_rule: optional cc_binary() rule override + cc_library_rule: optional cc_library() rule override + py_library_rule: optional py_library() rule override + + Additional keyword arguments are those common to all rules. + """ + rosidl_c_library( + name = _make_private_name(name, "__rosidl_c"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_public_label(dep, "_c") for dep in deps], + cc_library_rule = cc_library_rule, + **kwargs + ) + + typesupports = {} + # NOTE: typesupport binary files must not have any leading + # underscores or C generated code will not be able to + # dlopen it. + if "rosidl_typesupport_introspection_c" in AVAILABLE_TYPESUPPORT_LIST: + rosidl_typesupport_introspection_c_library( + name = _make_public_name(name, "__rosidl_typesupport_introspection_c"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_c")] + [ + _make_public_label(dep, "_c") for dep in deps], + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + **kwargs + ) + typesupports["rosidl_typesupport_introspection_c"] = \ + _make_public_label(name, "__rosidl_typesupport_introspection_c") + + if "rosidl_typesupport_fastrtps_c" in AVAILABLE_TYPESUPPORT_LIST: + rosidl_typesupport_fastrtps_c_library( + name = _make_public_name(name, "__rosidl_typesupport_fastrtps_c"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_c")] + [ + _make_public_label(dep, "_c") for dep in deps], + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + **kwargs + ) + typesupports["rosidl_typesupport_fastrtps_c"] = \ + _make_public_label(name, "__rosidl_typesupport_fastrtps_c") + + rosidl_typesupport_c_library( + name = _make_public_name(name, "__rosidl_typesupport_c"), + typesupports = typesupports, + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + deps = [_make_private_label(name, "__rosidl_c")] + [ + _make_public_label(dep, "_c") for dep in deps], + cc_binary_rule = cc_binary_rule, + **kwargs + ) + typesupports["rosidl_typesupport_c"] = \ + _make_public_label(name, "__rosidl_typesupport_c") + + cc_library_rule( + name = _make_public_name(name, "_c"), + srcs = typesupports.values(), + deps = [ + _make_private_label(name, "__rosidl_c") + ] + typesupports.values(), + linkstatic = True, + **kwargs + ) + + rosidl_py_library( + name = _make_public_name(name, "_py"), + typesupports = typesupports, + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + py_deps = [_make_public_label(dep, "_py") for dep in deps], + c_deps = [_make_public_label(name, "_c")] + [ + _make_public_label(dep, "_c") for dep in deps], + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + py_library_rule = py_library_rule, + **kwargs + ) + +def rosidl_interfaces_group( + name, interfaces, deps = [], group = None, + cc_binary_rule = native.cc_binary, + cc_library_rule = native.cc_library, + py_library_rule = native.py_library, + **kwargs +): + """ + Generates and builds C++ and Python ROS 2 interfaces. + + To depend on C++ interfaces, use the `_cc` target. + To depend on Python interfaces, use the `_py` target. + + Args: + name: interface group name, used as prefix for target names + interfaces: interface definition files + deps: optional interface group dependencies + group: optional interface group name override, useful when + target name cannot be forced to match the intended package + name for these interfaces + cc_binary_rule: optional cc_binary() rule override + cc_library_rule: optional cc_library() rule override + py_library_rule: optional py_library() rule override + + Additional keyword arguments are those common to all rules. + """ + rosidl_definitions_filegroup( + name = _make_public_name(name, "_defs"), + group = group or name, + interfaces = interfaces, + includes = [_make_public_label(dep, "_defs") for dep in deps], + **kwargs + ) + + rosidl_cc_support( + name, interfaces, deps, group, + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + **kwargs + ) + + rosidl_py_support( + name, interfaces, deps, group, + cc_binary_rule = cc_binary_rule, + cc_library_rule = cc_library_rule, + py_library_rule = py_library_rule, + **kwargs + ) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/ament_cmake_CMakeLists.txt.in b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/ament_cmake_CMakeLists.txt.in new file mode 100644 index 000000000..02d583442 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/ament_cmake_CMakeLists.txt.in @@ -0,0 +1,40 @@ +# @NAME@ is an ament CMake project to help collect @PACKAGE@'s exported +# configuration. It does so by means of an empty binary library that +# declares a direct dependency on @PACKAGE@. All relevant paths and +# flags can then be retrived from CMake's code model (e.g. using +# CMake's server mode on this project). +cmake_minimum_required(VERSION 3.5) +project(@NAME@ C CXX) + +find_package(ament_cmake REQUIRED) +find_package(@PACKAGE@ REQUIRED) + +file(WRITE empty.cc "") +add_library(${PROJECT_NAME} SHARED empty.cc) +if("@PACKAGE@" STREQUAL "rviz_ogre_vendor") + # TODO(hidmic): generalize special case handling + set(OGRE_TARGETS) + if(TARGET rviz_ogre_vendor::OgreMain) + list(APPEND OGRE_TARGETS rviz_ogre_vendor::OgreMain) + endif() + if(TARGET rviz_ogre_vendor::OgreOverlay) + list(APPEND OGRE_TARGETS rviz_ogre_vendor::OgreOverlay) + endif() + if(TARGET rviz_ogre_vendor::RenderSystem_GL) + list(APPEND OGRE_TARGETS rviz_ogre_vendor::RenderSystem_GL) + endif() + if(TARGET rviz_ogre_vendor::OgreGLSupport) + list(APPEND OGRE_TARGETS rviz_ogre_vendor::OgreGLSupport) + endif() + target_link_libraries(${PROJECT_NAME} + ${OGRE_TARGETS} ${OGRE_LIBRARIES} ${OGRE_PLUGINS} + ) + file(GLOB ogre_plugin_libraries "${OGRE_PLUGIN_DIR}/*.so*") + target_link_libraries(${PROJECT_NAME} ${ogre_plugin_libraries}) +else() + ament_target_dependencies(${PROJECT_NAME} @PACKAGE@) + # TODO(hidmic): figure out why this is sometimes necessary + if(TARGET @PACKAGE@) + target_link_libraries(${PROJECT_NAME} @PACKAGE@) + endif() +endif() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/distro.bzl.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/distro.bzl.tpl new file mode 100644 index 000000000..1cb010e84 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/distro.bzl.tpl @@ -0,0 +1,9 @@ +AVAILABLE_TYPESUPPORT_LIST = @AVAILABLE_TYPESUPPORT_LIST@ + +REPOSITORY_ROOT = @REPOSITORY_ROOT@ + +# Prepend full paths to not break workspace overlays +RUNTIME_ENVIRONMENT = { + "AMENT_PREFIX_PATH": ["path-prepend"] + @AMENT_PREFIX_PATH@, + "${LOAD_PATH}": ["path-prepend"] + @LOAD_PATH@, +} diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/overlay_executable.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/overlay_executable.bazel.tpl new file mode 100644 index 000000000..9213412d3 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/overlay_executable.bazel.tpl @@ -0,0 +1,6 @@ +ros_import_binary( + name = @name@, + executable = @executable@, + data = @data@, + deps = @deps@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_alias.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_alias.bazel.tpl new file mode 100644 index 000000000..8bcb25700 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_alias.bazel.tpl @@ -0,0 +1,4 @@ +alias( + name = @name@, + actual = @actual@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_cc_library.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_cc_library.bazel.tpl new file mode 100644 index 000000000..f9e992cc9 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_cc_library.bazel.tpl @@ -0,0 +1,11 @@ +cc_library( + name = @name@, + srcs = @srcs@, + hdrs = glob(["{}/**/*.*".format(x) for x in @headers@]), + includes = @includes@, + copts = @copts@, + defines = @defines@, + linkopts = @linkopts@, + data = @data@, + deps = @deps@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_interfaces_filegroup.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_interfaces_filegroup.bazel.tpl new file mode 100644 index 000000000..b02882647 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_interfaces_filegroup.bazel.tpl @@ -0,0 +1,4 @@ +interfaces_filegroup( + name = @name@, + share_directory = @share_directory@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_meta_py_library.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_meta_py_library.bazel.tpl new file mode 100644 index 000000000..68455c674 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_meta_py_library.bazel.tpl @@ -0,0 +1,4 @@ +py_library( + name = @name@, + deps = @deps@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library.bazel.tpl new file mode 100644 index 000000000..764978217 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library.bazel.tpl @@ -0,0 +1,14 @@ +py_library( + name = @name@, + srcs = glob(["{}/**/*.py".format(x) for x in @tops@]), + data = glob( + include=[ + "{}/**/*.*".format(x) for x in @tops@ + ] + [ + "{}/*".format(x) for x in @eggs@ + ], + exclude=["**/*.py", "**/*.so"], + ) + @data@, + imports = @imports@, + deps = @deps@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library_with_cc_libs.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library_with_cc_libs.bazel.tpl new file mode 100644 index 000000000..6b51de3c5 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_py_library_with_cc_libs.bazel.tpl @@ -0,0 +1,21 @@ +cc_library( + name = @cc_name@, + srcs = @cc_libs@, + data = @cc_libs@, + deps = @cc_deps@, +) + +py_library( + name = @name@, + srcs = glob(["{}/**/*.py".format(x) for x in @tops@]), + data = glob( + include=[ + "{}/**/*.*".format(x) for x in @tops@ + ] + [ + "{}/*".format(x) for x in @eggs@ + ], + exclude=["**/*.py", "**/*.so"], + ) + @data@, + imports = @imports@, + deps = @deps@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_share_filegroup.bazel.tpl b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_share_filegroup.bazel.tpl new file mode 100644 index 000000000..1aa3e9cb9 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/package_share_filegroup.bazel.tpl @@ -0,0 +1,4 @@ +share_filegroup( + name = @name@, + share_directories = @share_directories@, +) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/prologue.bazel b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/prologue.bazel new file mode 100644 index 000000000..5cb0d74eb --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/prologue.bazel @@ -0,0 +1,8 @@ +# -*- python -*- +# vi: set ft=python : + +package(default_visibility = ["//visibility:public"]) + +load(":common.bzl", "interfaces_filegroup") +load(":common.bzl", "share_filegroup") +load(":ros_py.bzl", "ros_import_binary") diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/setup.sh.in b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/setup.sh.in new file mode 100644 index 000000000..9f6e18098 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/resources/templates/setup.sh.in @@ -0,0 +1,13 @@ +#! /bin/sh + +# Source all workspaces +for ws in @WORKSPACES@; do + . $ws/setup.sh +done + +# Setup environment +export CMAKE_PREFIX_PATH="@CMAKE_PREFIX_PATH@:$CMAKE_PREFIX_PATH" +export PYTHONPATH="@REPOSITORY_DIR@:$PYTHONPATH" +export PATH="@REPOSITORY_DIR@:$PATH" + +exec $@ diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2.bzl b/drake_ros_bazel_installed/tools/skylark/ros2/ros2.bzl new file mode 100644 index 000000000..c97dac05c --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2.bzl @@ -0,0 +1,112 @@ +# -*- python -*- + +load("@drake_ros//tools/skylark:execute.bzl", "execute_or_fail") + +PACKAGE_MANIFEST = [ + "common.bzl", + "ros_cc.bzl", + "ros_py.bzl", + "rosidl.bzl", + + "rmw_isolation/__init__.py", + "rmw_isolation/generate_isolated_rmw_env.py", + "rmw_isolation/rmw_isolation.cc", + "rmw_isolation/rmw_isolation.h", + "rmw_isolation/rmw_isolation.py", + "rmw_isolation/test/isolated_listener.cc", + "rmw_isolation/test/isolated_listener.py", + "rmw_isolation/test/isolated_talker.cc", + "rmw_isolation/test/isolated_talker.py", + "rmw_isolation/test/rmw_isolation_test.sh", +] + +GENERATE_TOOL_RESOURCES_MANIFEST = [ + "cmake_tools/packages.py", + "cmake_tools/server_mode.py", + "cmake_tools/__init__.py", + + "resources/templates/distro.bzl.tpl", + "resources/templates/overlay_executable.bazel.tpl", + "resources/templates/package_interfaces_filegroup.bazel.tpl", + "resources/templates/package_cc_library.bazel.tpl", + "resources/templates/package_meta_py_library.bazel.tpl", + "resources/templates/package_py_library.bazel.tpl", + "resources/templates/package_py_library_with_cc_libs.bazel.tpl", + "resources/templates/package_share_filegroup.bazel.tpl", + "resources/templates/prologue.bazel", + + "resources/templates/ament_cmake_CMakeLists.txt.in", + + "ros2bzl/utilities.py", + "ros2bzl/sandboxing.py", + "ros2bzl/resources.py", + "ros2bzl/templates.py", + "ros2bzl/__init__.py", + "ros2bzl/scraping/system.py", + "ros2bzl/scraping/metadata.py", + "ros2bzl/scraping/ament_python.py", + "ros2bzl/scraping/ament_cmake.py", + "ros2bzl/scraping/__init__.py", +] + +def _label(relpath): + return Label("//tools/skylark/ros2:" + relpath) + +def _impl(repo_ctx): + repo_ctx.symlink( + _label("resources/rmw_isolation/package.BUILD.bazel"), + "rmw_isolation/BUILD.bazel") + for relpath in PACKAGE_MANIFEST: + repo_ctx.symlink(_label("resources/" + relpath), relpath) + + for relpath in GENERATE_TOOL_RESOURCES_MANIFEST: + repo_ctx.symlink(_label(relpath), relpath) + + repo_ctx.template( + "setup.sh", _label("resources/templates/setup.sh.in"), + substitutions = { + "@REPOSITORY_DIR@": str(repo_ctx.path(".")), + "@WORKSPACES@": " ".join(repo_ctx.attr.workspaces), + "@CMAKE_PREFIX_PATH@": ":".join(repo_ctx.attr.workspaces), + }, + executable = True + ) + + generate_tool = repo_ctx.path(_label("generate_repository_files.py")) + cmd = ["./setup.sh", str(generate_tool)] + for ws in repo_ctx.attr.workspaces: + ws_in_sandbox = ws.replace("/", "_") + cmd.extend(["-s", ws + ":" + ws_in_sandbox]) + for pkg in repo_ctx.attr.include_packages: + cmd.extend(["-i", pkg]) + for pkg in repo_ctx.attr.exclude_packages: + cmd.extend(["-e", pkg]) + for target, data in repo_ctx.attr.extra_data.items(): + for label in data: + cmd.extend(["-x", target + ".data+=" + label]) + if repo_ctx.attr.jobs > 0: + cmd.extend(["-j", repr(repo_ctx.attr.jobs)]) + cmd.append(repo_ctx.name) + execute_or_fail(repo_ctx, cmd, quiet=True) + +ros2_local_repository = repository_rule( + attrs = dict( + workspaces = attr.string_list(mandatory = True), + include_packages = attr.string_list(), + exclude_packages = attr.string_list(), + extra_data = attr.string_list_dict(), + jobs = attr.int(default=0), + ), + local = False, + implementation = _impl, +) +""" +Scrapes ROS 2 workspaces and exposes its artifacts as a Bazel local repository. + +Args: + workspaces: paths to ROS 2 workspace install trees. Each workspace specified overlays the previous one. + include_packages: optional set of packages to include, with its recursive dependencies. Defaults to all. + exclude_packages: optional set of packages to exclude, with precedence over included packages. Defaults to none. + extra_data: optional extra data dependencies for given targets + jobs: number of CMake jobs to use during package configuration and scrapping. Defaults to using all cores. +""" diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/__init__.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/resources.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/resources.py new file mode 100644 index 000000000..023d2001b --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/resources.py @@ -0,0 +1,20 @@ +import functools +import json +import os + +import ros2bzl.sandboxing as sandboxing + + +PATH_TO_RESOURCES = os.path.realpath( + os.path.join(os.path.dirname(__file__), '..', 'resources') +) + + +def path_to_resource(name): + return os.path.join(PATH_TO_RESOURCES, name) + + +@functools.lru_cache(maxsize=None) +def load_resource(name): + with open(path_to_resource(name), 'r') as fd: + return fd.read() diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/sandboxing.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/sandboxing.py new file mode 100644 index 000000000..237bb8c7c --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/sandboxing.py @@ -0,0 +1,44 @@ +""" +Utility functions to aid repository sandboxing of dependencies +external to the enclosing Bazel workspace. +""" + +import os + + +def make_symlink_forest_mapping(name, mapping): + """ + Makes a function that maps paths outside a Bazel workspace + to paths within a repository in that workspace. + + All provided outer directory paths are symlinked at their + corresponding inner directory paths. Paths that cannot be + mapped will be returned unchanged. For paths to be found + among runfiles in runtime, set the ``external`` keyword + argument to True. + + :param name: name of the enclosing repository + :param mapping: a key-value mapping from outer directory paths + to inner directory paths + :returns: callable that takes a path outside the workspace + and yields a path inside the workspace, and optionall takes + an ``external`` keyword argument. + """ + def _map(path, external=False): + path = os.path.normpath(path) + for outer_path, inner_path in mapping.items(): + if path.startswith(outer_path): + path = os.path.normpath( + path.replace(outer_path, inner_path) + ) + if external: + path = os.path.join(name, path) + return path + return path + + for outer_path, inner_path in mapping.items(): + if inner_path == '.': + continue + os.symlink(outer_path, inner_path, target_is_directory=True) + + return _map diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/__init__.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/__init__.py new file mode 100644 index 000000000..9132572e4 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/__init__.py @@ -0,0 +1,96 @@ +import os + +import ament_index_python +import cmake_tools + +from ros2bzl.scraping.metadata import collect_cmake_package_metadata +from ros2bzl.scraping.metadata import collect_ros_package_metadata + + +def list_all_executables(): + executables = {} + for prefix in ament_index_python.get_packages_with_prefixes().values(): + bindir = os.path.join(prefix, 'bin') + if not os.path.isdir(bindir): + continue + for path in os.listdir(bindir): + path = os.path.join(bindir, path) + if os.path.isfile(path) and os.access(path, os.X_OK): + name = os.path.basename(path) + if name not in executables: + executables[name] = path + return list(executables.values()) + + +def index_all_packages(): + packages = { + name: collect_ros_package_metadata(name, prefix) + for name, prefix in + ament_index_python.get_packages_with_prefixes().items() + } + for name, prefix in cmake_tools.get_packages_with_prefixes().items(): + if name in packages: + # Assume unique package names across package types + continue + packages[name] = collect_cmake_package_metadata(name, prefix) + return packages + + +def build_dependency_graph(packages, include=None, exclude=None): + package_set = set(packages) + if include: + package_set &= include + if exclude: + package_set -= exclude + + groups = {} + for name, metadata in packages.items(): + if 'groups' not in metadata: + continue + for group_name in metadata['groups']: + groups.setdefault(group_name, []) + groups[group_name].append(name) + + dependency_graph = {} + while package_set: + name = package_set.pop() + metadata = packages[name] + dependencies = set(metadata.get('build_export_dependencies', [])) + dependencies.update(metadata.get('run_dependencies', [])) + if 'group_dependencies' in metadata: + for group_name in metadata['group_dependencies']: + dependencies.update(groups[group_name]) + if exclude: + dependencies -= exclude + # Ignore system, non-ROS dependencies + # NOTE(hidmic): shall we sandbox those too? + dependencies = { + dependency_name + for dependency_name in dependencies + if dependency_name in packages + } + dependency_graph[name] = dependencies + package_set.update(dependencies) + + packages = {name: packages[name] for name in dependency_graph} + + return packages, dependency_graph + + +def load_distribution(sandbox, include=None, exclude=None): + packages, dependency_graph = build_dependency_graph( + index_all_packages(), include, exclude) + executables = list_all_executables() + return { + 'packages': packages, + 'dependency_graph': dependency_graph, + 'executables': executables, + 'paths': { + 'ament_prefix': [ + sandbox(path, external=True) for path in + ament_index_python.get_search_paths()], + 'library_load': [ + sandbox(path, external=True) for path in + os.environ['LD_LIBRARY_PATH'].split(os.path.pathsep)], + } + } diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_cmake.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_cmake.py new file mode 100644 index 000000000..017045fce --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_cmake.py @@ -0,0 +1,250 @@ +import glob +import os + +from multiprocessing.dummy import Pool +from tempfile import TemporaryDirectory + +import cmake_tools + +from ros2bzl.resources import path_to_resource + +from ros2bzl.scraping.system import find_library_path +from ros2bzl.scraping.system import find_library_dependencies +from ros2bzl.scraping.system import is_system_include +from ros2bzl.scraping.system import is_system_library + + +def collect_ament_cmake_shared_library_codemodel( + codemodel, additional_libraries +): + assert codemodel['type'] == 'SHARED_LIBRARY' + + link_flags = [] + if 'linkFlags' in codemodel: + for flag in codemodel['linkFlags'].split(' '): + flag = flag.strip() + if not flag: + continue + link_flags.append(flag) + + link_libraries = [] + if 'linkLibraries' in codemodel: + libraries = [] + for item in codemodel['linkLibraries'].split(' '): + item = item.strip() + if item.startswith('-') and not item.startswith('-l'): + link_flags.append(item) + continue + libraries.append(item) + for library in additional_libraries: + if library in libraries: + continue + libraries.append(library) + local_link_libraries = [] + for library in libraries: + if not os.path.isabs(library): + if library.startswith('-l'): + library = library[2:] + library = find_library_path( + library, + # Use linker options as well + link_flags=link_flags + ) + if not library: + # Ignore and keep going + continue + # Some packages do not fully export runtime dependencies. + # NOTE(hidmic): can the CMake registry be used instead + # of the ament index to mitigate this? + library_plus_dependencies = [library] + library_plus_dependencies += list( + find_library_dependencies(library) + ) + # Remove duplicates maintaining order. + for library in library_plus_dependencies: + if library in link_libraries: + continue + if is_system_library(library): + if library.startswith('/usr/local'): + local_link_libraries.append(library) + continue + link_libraries.append(library) + # Fail on any /usr/local libraries + if local_link_libraries: + error_message = 'Found libraries under /usr/local: ' + error_message += ', '.join(local_link_libraries) + raise RuntimeError(error_message) + + file_groups = codemodel['fileGroups'] + assert len(file_groups) == 1 + file_group = file_groups[0] + + include_directories = [] + if 'includePath' in file_group: + local_include_directories = [] + for entry in file_group['includePath']: + path = entry['path'] + if is_system_include(path): + if path.startswith('/usr/local'): + local_include_directories.append(path) + continue + include_directories.append(path) + # Fail on any /usr/local include directories + if local_include_directories: + error_message = 'Found include directories under /usr/local: ' + error_message += ', '.join(local_include_directories) + raise RuntimeError(error_message) + + defines = [] + if 'defines' in file_group: + ignored_defines = [ + codemodel['name'] + '_EXPORTS' # modern CMake specific + ] + for define in file_group['defines']: + if define in ignored_defines: + continue + defines.append(define) + + compile_flags = [] + if 'compileFlags' in file_group: + ignored_compile_flags = [ + '-fPIC' # applies to shared libraries only + ] + for flag in file_group['compileFlags'].split(' '): + flag = flag.strip() + if not flag or flag in ignored_compile_flags: + continue + compile_flags.append(flag) + + return { + 'include_directories': include_directories, + 'compile_flags': compile_flags, + 'defines': defines, + 'link_directories': [], # no directories in codemodel? + 'link_libraries': link_libraries, + 'link_flags': link_flags + } + + +def collect_ament_cmake_package_properties(name, metadata): + # NOTE(hidmic): each package properties are analyzed in isolation + # to preclude potential interactions if multiple packages were + # brought into the same CMake run. The latter could be done for + # speed + with TemporaryDirectory(dir=os.getcwd()) as project_path: + project_name = 'empty_using_' + name + cmakelists_template_path = path_to_resource( + 'templates/ament_cmake_CMakeLists.txt.in') + cmakelists_path = os.path.join(project_path, 'CMakeLists.txt') + cmake_tools.configure_file(cmakelists_template_path, cmakelists_path, { + '@NAME@': project_name, '@PACKAGE@': name + }) + + cmake_prefix_path = os.path.realpath(metadata['prefix']) + if 'AMENT_PREFIX_PATH' in os.environ: + ament_prefix_path = os.environ['AMENT_PREFIX_PATH'] + cmake_prefix_path += ';' + ament_prefix_path.replace(':', ';') + + try: + with cmake_tools.server_mode(project_path) as cmake: + cmake.configure(attributes={'cacheArguments': [ + '-DCMAKE_PREFIX_PATH="{}"'.format(cmake_prefix_path) + ]}, timeout=30, message_callback=print) + cmake.compute(timeout=20, message_callback=print) + codemodel = cmake.codemodel(timeout=10) + except Exception: + import shutil + shutil.rmtree('error_case', ignore_errors=True) + shutil.copytree(project_path, 'error_case') + raise + + configurations = codemodel['configurations'] + assert len(configurations) == 1 + configuration = configurations[0] + + projects = configuration['projects'] + assert len(projects) == 1 + project = projects[0] + + targets = {t['name']: t for t in project['targets']} + assert project_name in targets + target = targets[project_name] + + additional_libraries = [] + if 'rosidl_interface_packages' in metadata.get('groups', []): + # Pick up extra shared libraries in interface packages for + # proper sandboxing + glob_pattern = os.path.join( + metadata['prefix'], 'lib', f'lib{name}__rosidl*.so') + additional_libraries.extend(glob.glob(glob_pattern)) + properties = collect_ament_cmake_shared_library_codemodel( + target, additional_libraries + ) + return properties + + +def collect_ament_cmake_package_direct_properties( + name, metadata, dependencies, cache +): + if 'ament_cmake' not in cache: + cache['ament_cmake'] = {} + ament_cmake_cache = cache['ament_cmake'] + + if name not in ament_cmake_cache: + ament_cmake_cache[name] = \ + collect_ament_cmake_package_properties(name, metadata) + + properties = dict(ament_cmake_cache[name]) + for dependency_name, dependency_metadata in dependencies.items(): + if dependency_metadata.get('build_type') != 'ament_cmake': + continue + if dependency_name not in ament_cmake_cache: + ament_cmake_cache[dependency_name] = \ + collect_ament_cmake_package_properties( + dependency_name, dependency_metadata) + dependency_properties = ament_cmake_cache[dependency_name] + + # Remove duplicates maintaining order. + properties['compile_flags'] = [ + flags for flags in properties['compile_flags'] + if flags not in dependency_properties['compile_flags'] + ] + properties['defines'] = [ + flags for flags in properties['defines'] + if flags not in dependency_properties['defines'] + ] + properties['link_flags'] = [ + flags for flags in properties['link_flags'] + if flags not in dependency_properties['link_flags'] + ] + properties['link_libraries'] = [ + library for library in properties['link_libraries'] + if library not in dependency_properties['link_libraries'] + ] + deduplicated_include_directories = [] + for directory in properties['include_directories']: + if directory in dependency_properties['include_directories']: + # We may be dealing with a merge install. + # Try leverage REP-122. + if not os.path.exists(os.path.join(directory, name)): + continue + deduplicated_include_directories.append(directory) + # Do not deduplicate link directories in case we're dealing with + # merge installs. + + return properties + + +def precache_ament_cmake_properties(packages, jobs=None): + ament_cmake_packages = { + name: metadata + for name, metadata in packages.items() + if metadata.get('build_type') == 'ament_cmake' + } + with Pool(jobs) as pool: + return dict(zip( + ament_cmake_packages.keys(), pool.starmap( + collect_ament_cmake_package_properties, + ament_cmake_packages.items() + ) + )) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_python.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_python.py new file mode 100644 index 000000000..892553547 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/ament_python.py @@ -0,0 +1,82 @@ +import glob +from importlib.metadata import distribution +from importlib.metadata import PackageNotFoundError +import sysconfig + +from ros2bzl.scraping.system import find_library_dependencies +from ros2bzl.scraping.system import is_system_library + + +EXTENSION_SUFFIX = sysconfig.get_config_var('EXT_SUFFIX') + + +def find_python_package(name): + dist = distribution(name) + top_level = dist.read_text('top_level.txt') + top_level = top_level.rstrip('\n') + return str(dist._path), str(dist.locate_file(top_level)) + + +def collect_ament_python_package_properties(name, metadata): + egg_path, top_level = find_python_package(name) + properties = {'python_packages': [(egg_path, top_level)]} + cc_libraries = glob.glob('{}/**/*.so'.format(top_level), recursive=True) + if cc_libraries: + cc_libraries.extend(set( + dep for library in cc_libraries + for dep in find_library_dependencies(library) + if not is_system_library(dep) + )) + properties['cc_extensions'] = [ + lib for lib in cc_libraries + if lib.endswith(EXTENSION_SUFFIX) + ] + properties['cc_libraries'] = [ + lib for lib in cc_libraries + if not lib.endswith(EXTENSION_SUFFIX) + ] + return properties + + +def collect_ament_python_package_direct_properties( + name, metadata, dependencies, cache +): + if 'ament_python' not in cache: + cache['ament_python'] = {} + ament_python_cache = cache['ament_python'] + + if name not in ament_python_cache: + ament_python_cache[name] = \ + collect_ament_python_package_properties(name, metadata) + + properties = dict(ament_python_cache[name]) + + if 'cc_libraries' in properties: + ament_cmake_cache = cache['ament_cmake'] + for dependency_name, dependency_metadata in dependencies.items(): + dependency_libraries = [] + if 'cc' in dependency_metadata.get('langs', []): + if dependency_metadata.get('build_type') == 'ament_cmake': + if dependency_name not in ament_cmake_cache: + ament_cmake_cache[dependency_name] = \ + collect_ament_cmake_package_properties( + dependency_name, dependency_metadata) + dependency_properties = ament_cmake_cache[dependency_name] + dependency_libraries.extend( + dependency_properties['link_libraries'] + ) + if 'py' in dependency_metadata.get('langs', []): + if dependency_name not in ament_python_cache: + ament_python_cache[dependency_name] = \ + collect_ament_python_package_properties( + dependency_name, dependency_metadata) + dependency_properties = ament_python_cache[dependency_name] + if 'cc_libraries' in dependency_properties: + dependency_libraries.extend( + dependency_properties['cc_libraries']) + # Remove duplicates maintaining order + properties['cc_libraries'] = [ + lib for lib in properties['cc_libraries'] + if lib not in dependency_libraries + ] + return properties diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/metadata.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/metadata.py new file mode 100644 index 000000000..5edb8184a --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/metadata.py @@ -0,0 +1,117 @@ +import os +import xml.etree.ElementTree as ET + + +def parse_package_xml(path_to_package_xml): + tree = ET.parse(path_to_package_xml) + + depends = set([ + tag.text for tag in tree.findall('./depend') + ]) + exec_depends = set([ + tag.text for tag in tree.findall('./exec_depend') + ]) + build_export_depends = set([ + tag.text for tag in tree.findall('./build_export_depend') + ]) + group_depends = set([ + tag.text for tag in tree.findall('./group_depend') + ]) + member_of_groups = set([ + tag.text for tag in tree.findall('./member_of_group') + ]) + build_type = tree.find('./export/build_type').text + + return dict( + build_export_dependencies=build_export_depends | depends, + run_dependencies=exec_depends | depends, + group_dependencies=group_depends, + groups=member_of_groups, + build_type=build_type + ) + + +def parse_plugins_description_xml(path_to_plugins_description_xml): + plugins_description_xml = ET.parse(path_to_plugins_description_xml) + root = plugins_description_xml.getroot() + assert root.tag == 'library' + return dict(plugin_libraries=[root.attrib['path']]) + + +def find_executables(base_path): + for dirpath, dirnames, filenames in os.walk(base_path): + # ignore folder starting with . + dirnames[:] = [d for d in dirnames if d[0] not in ['.']] + dirnames.sort() + # select executable files + for filename in sorted(filenames): + path = os.path.join(dirpath, filename) + if os.access(path, os.X_OK): + yield path + + +DEFAULT_LANGS_PER_BUILD_TYPE = { + 'cmake': {'cc'}, + 'ament_cmake': {'cc'}, + 'ament_python': {'py'}, +} + + +def collect_package_langs(metadata): + langs = set() + build_type = metadata.get('build_type') + if build_type in DEFAULT_LANGS_PER_BUILD_TYPE: + langs.update(DEFAULT_LANGS_PER_BUILD_TYPE[build_type]) + return langs + + +def collect_cmake_package_metadata(name, prefix): + metadata = dict( + prefix=prefix, + build_type='cmake', + ) + metadata['langs'] = collect_package_langs(metadata) + return metadata + + +def collect_ros_package_metadata(name, prefix): + """ + Collects ROS package metadata. + + Metadata includes package `prefix`, `share_directory`, + `ament_index_directory`, `build_type`, `build_export_dependencies`, + `run_dependencies`, `group_dependencies`, `groups`, `plugin_libraries`, + `executables`, and `langs` (i.e expected code languages). + + This function supports both symlink and merged install workspaces. + + :param name: ROS package name + :param prefix: ROS package install prefix + :returns: metadata as a dictionary + """ + share_directory = os.path.join(prefix, 'share', name) + ament_index_directory = os.path.join(prefix, 'share', 'ament_index') + + metadata = dict( + prefix=prefix, + share_directory=share_directory, + ament_index_directory=ament_index_directory, + ) + + lib_directory = os.path.join(prefix, 'lib', name) + metadata['executables'] = list(find_executables(lib_directory)) + + path_to_package_xml = os.path.join(share_directory, 'package.xml') + metadata.update(parse_package_xml(path_to_package_xml)) + + metadata['langs'] = collect_package_langs(metadata) + + path_to_plugins_description_xml = os.path.join( + share_directory, 'plugins_description.xml' + ) + if os.path.exists(path_to_plugins_description_xml): + metadata.update(parse_plugins_description_xml( + path_to_plugins_description_xml + )) + + return metadata diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/system.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/system.py new file mode 100644 index 000000000..8ad570bb0 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/scraping/system.py @@ -0,0 +1,118 @@ +""" +Utility functions to aid compilation and linkage configuration scraping by +resorting to (Linux) system-wide conventions and tooling. +""" + +import os +import re +import subprocess +import sys + + +# Standard include files' search paths for compilers in Linux systems. +# Useful to detect system includes in package exported configuration. +DEFAULT_INCLUDE_DIRECTORIES = ['/usr/include', '/usr/local/include'] + + +def is_system_include(include_path): + """ + Checks whether `include_path` is in a system include directory + i.e. known to compilers. + """ + include_path = os.path.realpath(include_path) + return any( + include_path.startswith(path) + for path in DEFAULT_INCLUDE_DIRECTORIES) + + +# Standard library files' search paths for linkers in Linux systems. +# Useful to detect system libraries in package exported configuration. +DEFAULT_LINK_DIRECTORIES = ['/lib', '/usr/lib', '/usr/local/lib'] + + +def is_system_library(library_path): + """ + Checks whether `library_path` is in a system library directory + i.e. known to linkers. + """ + library_path = os.path.realpath(library_path) + return any( + library_path.startswith(path) + for path in DEFAULT_LINK_DIRECTORIES) + + +LD_LIBRARY_PATHS = [ + path for path in os.environ.get('LD_LIBRARY_PATH', '').split(':') if path] + + +def find_library_path(library_name, link_directories=None, link_flags=None): + """ + Finds the path to a library. + + To do so it relies on the GNU `ld` linker and its library naming spec, + effectively reusing all of its search logic. + + :param library_name: name of the library to be searched + :param link_directories: optional list of directories to search in + :param link_flags: optional list of linker flags to account for + :returns: the path to the library if found, None otherwise + """ + # Adapted from ctypes.util.find_library_path() implementation. + pattern = r'/(?:[^\(\)\s]*/)*lib{}\.[^\(\)\s]*'.format(library_name) + + cmd = ['ld', '-t'] + if link_directories: + for path in link_directories: + cmd.extend(['-L', path]) + if link_flags: + cmd.extend(link_flags) + for path in LD_LIBRARY_PATHS: + cmd.extend(['-L', path]) + for path in DEFAULT_LINK_DIRECTORIES: + cmd.extend(['-L', path]) + cmd.extend(['-o', os.devnull, '-l' + library_name]) + try: + out = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.DEVNULL, + encoding='utf8' + ).stdout.strip() + match = re.search(pattern, out) + if match: + path = match.group(0) + # Remove double forward slashes, if any + return os.path.join('/', os.path.relpath(path, '/')) + except Exception: + pass + return None + + +LDD_LINE_PATTERN = re.compile(r' => (/(?:[^\(\)\s]*/)*lib[^\(\)\s]*)') + + +def find_library_dependencies(library_path): + """ + Lists all shared library dependencies of a given library. + + To do so, it relies on the `ldd` tool as found in Linux. + + :param library_path: path to the library to be inspected + :returns: a generator that iterates over the paths to + library dependencies + """ + try: + lines = subprocess.run( + ['ldd', library_path], + check=True, + stdout=subprocess.PIPE, + encoding='utf8' + ).stdout.strip().split('\n') + for line in lines: + match = LDD_LINE_PATTERN.search(line.strip()) + if match: + yield match.group(1) + except Exception: + pass + return diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/templates.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/templates.py new file mode 100644 index 000000000..4405c5387 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/templates.py @@ -0,0 +1,309 @@ +import os + +from ros2bzl.resources import load_resource +from ros2bzl.scraping.system import find_library_path + +from ros2bzl.utilities import to_starlark_string_dict + + +def label_name(name, metadata): + return metadata.get('bazel_name', name) + + +def label(name, metadata): + workspace = metadata.get('bazel_workspace', '') + if workspace: + workspace = '@' + workspace + package = metadata.get('bazel_package', '') + if workspace or package: + package = '//' + package + return workspace + package + ':' + label_name(name, metadata) + + +def labels_with(suffix): + return ( + (lambda *args, **kwargs: label_name(*args, **kwargs) + suffix), + (lambda *args, **kwargs: label(*args, **kwargs) + suffix) + ) + + +share_name, share_label = labels_with(suffix='_share') +c_name, c_label = labels_with(suffix='_c') +cc_name, cc_label = labels_with(suffix='_cc') +py_name, py_label = labels_with(suffix='_py') +meta_py_name, meta_py_label = labels_with(suffix='_transitive_py') + + +def configure_package_share_filegroup(name, metadata, sandbox): + target_name = share_name(name, metadata) + shared_directories = [sandbox(metadata['share_directory'])] + if 'ament_index_directory' in metadata: + shared_directories.append(sandbox(metadata['ament_index_directory'])) + return ( + target_name, + load_resource('templates/package_share_filegroup.bazel.tpl'), + to_starlark_string_dict({ + 'name': target_name, 'share_directories': shared_directories + }) + ) + + +def configure_package_interfaces_filegroup(name, metadata, sandbox): + return ( + name, + load_resource('templates/package_interfaces_filegroup.bazel.tpl'), + to_starlark_string_dict({ + 'name': name, + 'share_directory': sandbox(metadata['share_directory']) + }) + ) + + +def configure_package_cc_library( + name, metadata, properties, dependencies, extras, sandbox +): + target_name = cc_name(name, metadata) + libraries = [sandbox(library) for library in properties['link_libraries']] + include_directories = [ + sandbox(include) for include in properties['include_directories']] + local_includes = [ + include for include in include_directories + if not os.path.isabs(include)] + headers = [] + for include in local_includes: + if not include.endswith(os.path.join(name, 'include')): + # Assume package lives in a merged install space + # Assume package abides to REP-122 FHS layout + include = os.path.join(include, name) + headers.append(include) + # Push remaining nonlocal includes through compiler options + copts = [ + '-isystem ' + include + for include in include_directories + if os.path.isabs(include)] + copts.extend(properties['compile_flags']) + defines = properties['defines'] + + linkopts = properties['link_flags'] + for link_directory in properties['link_directories']: + link_directory = sandbox(link_directory) + if not link_directory: + continue + linkopts += [ + '-L' + link_directory, + '-Wl,-rpath ' + link_directory + ] + deps = [ + cc_label(dependency_name, dependency_metadata) + for dependency_name, dependency_metadata in dependencies.items() + if 'cc' in dependency_metadata.get('langs', []) + ] + + data = [] + if 'share_directory' in metadata: + data.append(share_label(name, metadata)) + # Add in plugins, if any + if 'plugin_libraries' in metadata: + data.extend( + sandbox(find_library_path(library)) + for library in metadata['plugin_libraries'] + ) + # Prepare runfiles to support dynamic loading + data.extend(library for library in libraries if library not in data) + if extras and 'data' in extras and target_name in extras['data']: + data.extend( + label_or_path + for label_or_path in extras['data'][target_name] + if label_or_path not in data + ) + + template_path = 'templates/package_cc_library.bazel.tpl' + + config = { + 'name': target_name, + 'srcs': libraries, + 'headers': headers, + 'includes': local_includes, + 'copts': copts, + 'defines': defines, + 'linkopts': linkopts, + 'data': data, + 'deps': deps, + } + + return ( + target_name, load_resource(template_path), + to_starlark_string_dict(config)) + + +def configure_package_meta_py_library(name, metadata, dependencies): + deps = [] + for dependency_name, dependency_metadata in dependencies.items(): + if 'py' in dependency_metadata.get('langs', []): + deps.append(py_label(dependency_name, dependency_metadata)) + elif 'py (transitive)' in dependency_metadata.get('langs', []): + deps.append(meta_py_label(dependency_name, dependency_metadata)) + target_name = meta_py_name(name, metadata) + return ( + target_name, + load_resource('templates/package_meta_py_library.bazel.tpl'), + to_starlark_string_dict({'name': target_name, 'deps': deps}) + ) + + +def configure_package_py_library( + name, metadata, properties, dependencies, extras, sandbox +): + target_name = py_name(name, metadata) + eggs = [sandbox(egg_path) for egg_path, _ in properties['python_packages']] + tops = [ + sandbox(top_level) for _, top_level in properties['python_packages']] + imports = [os.path.dirname(egg) for egg in eggs] + + template = 'templates/package_py_library.bazel.tpl' + config = { + 'name': target_name, + 'tops': tops, + 'eggs': eggs, + 'imports': imports + } + + deps = [] + for dependency_name, dependency_metadata in dependencies.items(): + if 'py' in dependency_metadata.get('langs', []): + deps.append(py_label(dependency_name, dependency_metadata)) + elif 'py (transitive)' in dependency_metadata.get('langs', []): + deps.append(meta_py_label(dependency_name, dependency_metadata)) + config['deps'] = deps + + data = [share_label(name, metadata)] + if 'cc' in metadata.get('langs', []): + data.append(cc_label(name, metadata)) + + if 'cc_extensions' in properties: + # Bring in C/C++ dependencies + cc_deps = [ + cc_label(dependency_name, dependency_metadata) + for dependency_name, dependency_metadata in dependencies.items() + if 'cc' in dependency_metadata.get('langs', []) + ] + # Expose C/C++ libraries if any + if 'cc_libraries' in properties: + template = 'templates/package_py_library_with_cc_libs.bazel.tpl' + config.update({ + 'cc_name': c_name("_" + target_name, metadata), + 'cc_libs': [ + sandbox(lib) for lib in properties['cc_libraries']], + 'cc_deps': cc_deps + }) + data.append(c_label("_" + target_name, metadata)) + else: + data.extend(cc_deps) + # Prepare runfiles to support dynamic loading + cc_extensions = [ + sandbox(extension) for extension in properties['cc_extensions']] + data.extend(cc_extensions) + + # Add in plugins, if any + if 'plugin_libraries' in metadata: + data.extend( + sandbox(find_library_path(library)) + for library in metadata['plugin_libraries'] + ) + + if extras and 'data' in extras: + data.extend(extras['data'].get(target_name, [])) + + config['data'] = data + + return ( + target_name, + load_resource(template), + to_starlark_string_dict(config) + ) + + +def configure_package_alias(name, target): + return ( + name, + load_resource('templates/package_alias.bazel.tpl'), + to_starlark_string_dict({'name': name, 'actual': ':' + target}) + ) + + +def configure_package_c_library_alias(name, metadata): + target_name = c_name(name, metadata) + return ( + target_name, + load_resource('templates/package_alias.bazel.tpl'), + to_starlark_string_dict({ + 'name': target_name, + 'actual': cc_label(name, metadata) + }) + ) + + +def configure_executable_imports( + executables, dependencies, sandbox, extras=None, prefix=None +): + deps = [] + common_data = [] + for dependency_name, dependency_metadata in dependencies.items(): + # TODO(hidmic): use appropriate target based on executable file type + if 'cc' in dependency_metadata.get('langs', []): + common_data.append(cc_label(dependency_name, dependency_metadata)) + if 'py' in dependency_metadata.get('langs', []): + deps.append(py_label(dependency_name, dependency_metadata)) + elif 'py (transitive)' in dependency_metadata.get('langs', []): + common_data.append(meta_py_label( + dependency_name, dependency_metadata)) + + for executable in executables: + target_name = os.path.basename(executable) + if prefix: + target_name = prefix + '_' + target_name + data = common_data + if extras and 'data' in extras and target_name in extras['data']: + data = data + extras['data'][target_name] + yield ( + target_name, + load_resource('templates/overlay_executable.bazel.tpl'), + to_starlark_string_dict({ + 'name': target_name, + 'executable': sandbox(executable), + 'data': data, 'deps': deps, + }) + ) + + +def configure_package_executable_imports( + name, metadata, dependencies, sandbox, extras=None +): + dependencies = dict(dependencies) + dependencies[name] = metadata + yield from configure_executable_imports( + metadata['executables'], dependencies, sandbox, + extras=extras, prefix=label_name(name, metadata) + ) + + +def configure_prologue(repo_name): + return load_resource('templates/prologue.bazel'), {} + + +def configure_distro(repo_name, distro): + typesupport_groups = [ + 'rosidl_typesupport_c_packages', + 'rosidl_typesupport_cpp_packages' + ] + return load_resource('templates/distro.bzl.tpl'), to_starlark_string_dict({ + 'AMENT_PREFIX_PATH': distro['paths']['ament_prefix'], + 'LOAD_PATH': distro['paths']['library_load'], # Linux only + 'AVAILABLE_TYPESUPPORT_LIST': [ + name for name, metadata in distro['packages'].items() if any( + group in typesupport_groups + for group in metadata.get('groups', []) + ) + ], + 'REPOSITORY_ROOT': '@{}//'.format(repo_name), + }) diff --git a/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/utilities.py b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/utilities.py new file mode 100644 index 000000000..77d457f61 --- /dev/null +++ b/drake_ros_bazel_installed/tools/skylark/ros2/ros2bzl/utilities.py @@ -0,0 +1,32 @@ +import functools +import json + + +class StarlarkEncoder(json.JSONEncoder): + # Use JSON format as a basis + def default(self, obj): + if isinstance(obj, bool): + return repr(obj) + return super().default(obj) + + +def to_starlark_string_dict(d): + encoder = StarlarkEncoder() + return { + k: encoder.encode(v) + for k, v in d.items() + } + + +def interpolate(template, config): + content = template + for key, value in config.items(): + content = content.replace('@{}@'.format(key), value) + return content + + +def compose(f, g): + @functools.wraps(f) + def wrapped(head, *args, **kwargs): + return f(g(head, *args, **kwargs), *args, **kwargs) + return wrapped diff --git a/drake_ros_bazel_installed/tools/workspace/BUILD.bazel b/drake_ros_bazel_installed/tools/workspace/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/workspace/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/workspace/python/BUILD.bazel b/drake_ros_bazel_installed/tools/workspace/python/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/workspace/python/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/workspace/python/repository.bzl b/drake_ros_bazel_installed/tools/workspace/python/repository.bzl new file mode 100644 index 000000000..ab57a20dd --- /dev/null +++ b/drake_ros_bazel_installed/tools/workspace/python/repository.bzl @@ -0,0 +1,102 @@ +load("//tools/skylark:execute.bzl", "execute_or_fail") + +VERSION_FILE_TEMPLATE = \ +""" +# -*- python -*- + +PYTHON_VERSION = "{}" +PYTHON_EXTENSION_SUFFIX = "{}" +""" + +BUILD_FILE_TEMPLATE = \ +""" +# -*- python -*- + +package(default_visibility = ["//visibility:public"]) + +cc_library( + name = "headers", + hdrs = glob( + include = ["include/**/*.*"], + exclude_directories = 1, + ), + includes = {}, +) + +cc_library( + name = "libs", + linkopts = {}, + deps = [":headers"], +) +""" + +def _impl(repo_ctx): + python_interpreter = repo_ctx.which("python3") + if python_interpreter == None: + fail("No python3 interpreter found in PATH") + python_config = repo_ctx.which("python3-config") + if python_config == None: + fail("No python3-config utility found in PATH") + + python_version = execute_or_fail( + repo_ctx, [ + python_interpreter, "-c" , + "import sys; v = sys.version_info;" + + "print('{}.{}'.format(v.major, v.minor))" + ] + ).stdout.strip() + + extension_suffix = execute_or_fail( + repo_ctx, [python_config, "--extension-suffix"] + ).stdout.strip() + + repo_ctx.file( + "version.bzl", + content = VERSION_FILE_TEMPLATE.format( + python_version, extension_suffix + ), + executable = False + ) + + cflags = execute_or_fail( + repo_ctx, [python_config, "--includes"] + ).stdout.strip().split(" ") + + includes = [] + for cflag in cflags: + if not cflag.startswith("-I"): + continue + include = cflag[2:] + sandboxed_include = "include/{}".format( + include.replace("/", "_")) + if sandboxed_include in includes: + continue + repo_ctx.symlink(include, sandboxed_include) + includes.append(sandboxed_include) + + linkopts = execute_or_fail( + repo_ctx, [python_config, "--ldflags"] + ).stdout.strip().split(" ") + + libpython = "python{}".format(python_version) + links_libpython = False + for opt in linkopts: + if opt.startswith("-l") and libpython in opt: + links_libpython = True + break + if not links_libpython: + linkopts.append("-l{}".format(libpython)) + + repo_ctx.file( + "BUILD.bazel", + content = BUILD_FILE_TEMPLATE.format( + includes, linkopts + ), + executable = False + ) + +python_repository = repository_rule( + implementation = _impl, + local = True, + configure = True +) diff --git a/drake_ros_bazel_installed/tools/workspace/ros2/BUILD.bazel b/drake_ros_bazel_installed/tools/workspace/ros2/BUILD.bazel new file mode 100644 index 000000000..cc844a317 --- /dev/null +++ b/drake_ros_bazel_installed/tools/workspace/ros2/BUILD.bazel @@ -0,0 +1 @@ +# Empty build file to mark package boundaries. diff --git a/drake_ros_bazel_installed/tools/workspace/ros2/repository.bzl b/drake_ros_bazel_installed/tools/workspace/ros2/repository.bzl new file mode 100644 index 000000000..ef5382e0f --- /dev/null +++ b/drake_ros_bazel_installed/tools/workspace/ros2/repository.bzl @@ -0,0 +1,20 @@ +load("//tools/skylark/ros2:ros2.bzl", "ros2_local_repository") + +ROS2_DIST = "rolling" + +def ros2_repository(name, overlays = []): + ros2_local_repository( + name = name, + workspaces = ['/opt/ros/{}'.format(ROS2_DIST)], + include_packages = [ + "action_msgs", + "builtin_interfaces", + "rosidl_default_generators", + "rclcpp_action", + "rclcpp", + "rclpy", + # RMW implementations + "rmw_cyclonedds_cpp", + "rmw_fastrtps_cpp", + ] + )