← Back to team overview

launchpad-reviewers team mailing list archive

[Merge] ~artivis/lpcraft:feature/ros-plugins into lpcraft:main

 

jeremie has proposed merging ~artivis/lpcraft:feature/ros-plugins into lpcraft:main.

Requested reviews:
  Launchpad code reviewers (launchpad-reviewers)

For more details, see:
https://code.launchpad.net/~artivis/lpcraft/+git/lpcraft/+merge/435371

This implements three new plugins for building and testing ROS & ROS 2 projects, namely,

- CatkinPlugin - based on the 'catkin' build tool
- CatkinToolsPlugin - based on the 'catkin-tools' build tool
- ColconPlugin - based on the 'colcon' build tool

These plugins all share a common base class - RosBasePlugin - factoring some common code.

The three plugins follow the same principle,

- Install the ROS project's dependencies during the 'run-before' step
- Build the ROS project during the 'run' step
- Build and run the tests during the 'run-after' step (optional)

The later is optional and depends on a plugin config parameter.

Beside some ROS-specific environment variables, the base class define the following env. vars.,

- ROS_PROJECT_BASE - the parent directory of the lpcraft build tree
- ROS_PROJECT_SRC - the directory of the project
- ROS_PROJECT_BUILD - the directory of the plugins build tree
- ROS_PROJECT_INSTALL - the directory of the plugins install tree

They somewhat mimic env. vars. in snapcraft allowing for out-of-source build/install. They also allow the end user to more easily override the run commands not having to figure out the internal paths. They could be replaced in the future by lpcraft-wide env. vars.
 

-- 
Your team Launchpad code reviewers is requested to review the proposed merge of ~artivis/lpcraft:feature/ros-plugins into lpcraft:main.
diff --git a/lpcraft/plugin/tests/test_plugins.py b/lpcraft/plugin/tests/test_plugins.py
index 1def73b..addaac0 100644
--- a/lpcraft/plugin/tests/test_plugins.py
+++ b/lpcraft/plugin/tests/test_plugins.py
@@ -5,7 +5,7 @@ import os
 import subprocess
 from pathlib import Path, PosixPath
 from textwrap import dedent
-from typing import List, Optional, Union, cast
+from typing import Any, Dict, List, Optional, Union, cast
 from unittest.mock import ANY, Mock, call, patch
 
 from craft_providers.lxd import launch
@@ -1080,3 +1080,646 @@ class TestPlugins(CommandBaseTestCase):
             lpcraft.config.Config.load,
             config_path,
         )
+
+    def _get_ros_before_run_call(self, expected_env: Dict[str, str]) -> Any:
+        return call(
+            [
+                "bash",
+                "--noprofile",
+                "--norc",
+                "-ec",
+                "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi"  # noqa:E501
+                "\nif [ ! -f /etc/ros/rosdep/sources.list.d/20-default.list ]; then\nrosdep init\nfi"  # noqa:E501
+                "\nrosdep update --rosdistro ${ROS_DISTRO}"
+                "\nrosdep install -i -y --rosdistro ${ROS_DISTRO} --from-paths .",  # noqa:E501
+            ],
+            cwd=PosixPath("/build/lpcraft/project"),
+            env=expected_env,
+            stdout=ANY,
+            stderr=ANY,
+        )
+
+    def _get_ros_packages_call(
+        self, expected_env: Dict[str, str], packages: List[str]
+    ) -> Any:
+        return call(
+            [
+                "apt",
+                "install",
+                "-y",
+                "build-essential",
+                "cmake",
+                "g++",
+                "python3-rosdep",
+            ]
+            + packages,
+            cwd=PosixPath("/build/lpcraft/project"),
+            env=expected_env,
+            stdout=ANY,
+            stderr=ANY,
+        )
+
+    def _get_ros_expected_env(
+        self, ros_distro: str, ros_python_version: str, ros_version: str
+    ) -> Dict[str, str]:
+        return {
+            "ROS_PROJECT_BASE": "/build/lpcraft",
+            "ROS_PROJECT_SRC": "/build/lpcraft/project",
+            "ROS_PROJECT_BUILD": "/build/lpcraft/build",
+            "ROS_PROJECT_INSTALL": "/build/lpcraft/install",
+            "ROS_DISTRO": ros_distro,
+            "ROS_PYTHON_VERSION": ros_python_version,
+            "ROS_VERSION": ros_version,
+        }
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_plugin(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin
+                    series: focal
+                    architectures: amd64
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["ros-noetic-catkin"]
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi"  # noqa:E501
+                        "\ncatkin_make_isolated --source-space ${ROS_PROJECT_SRC} --directory ${ROS_PROJECT_BASE}",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_plugin_with_tests(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["ros-noetic-catkin", "file", "git"]
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi"  # noqa:E501
+                        "\ncatkin_make_isolated --source-space ${ROS_PROJECT_SRC} --directory ${ROS_PROJECT_BASE}",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "\nsource ${ROS_PROJECT_BASE}/devel_isolated/setup.sh"
+                        "\ncatkin_make_isolated --source-space ${ROS_PROJECT_SRC} --directory ${ROS_PROJECT_BASE} --force-cmake --catkin-make-args run_tests"  # noqa:E501
+                        "\ncatkin_test_results --verbose ${ROS_PROJECT_BASE}/build_isolated/",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_plugin_user_command(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    run-before: echo 'hello'
+                    run: echo 'robot'
+                    # run-after: "we shouldn't get anything unless uncommented"
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["ros-noetic-catkin", "file", "git"]
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'hello'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'robot'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_tools_plugin(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin-tools
+                    series: focal
+                    architectures: amd64
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["python3-catkin-tools"]
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi\n"  # noqa:E501
+                        "catkin init --workspace ${ROS_PROJECT_BASE}\n"
+                        "catkin profile add --force lpcraft\n"
+                        "catkin config --profile lpcraft --install --source-space ${ROS_PROJECT_SRC} --build-space ${ROS_PROJECT_BUILD} --install-space ${ROS_PROJECT_INSTALL}\n"  # noqa:E501
+                        "catkin build --no-notify --profile lpcraft",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_tools_plugin_with_tests(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin-tools
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["python3-catkin-tools", "file", "git"]
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi\n"  # noqa:E501
+                        "catkin init --workspace ${ROS_PROJECT_BASE}\n"
+                        "catkin profile add --force lpcraft\n"
+                        "catkin config --profile lpcraft --install --source-space ${ROS_PROJECT_SRC} --build-space ${ROS_PROJECT_BUILD} --install-space ${ROS_PROJECT_INSTALL}\n"  # noqa:E501
+                        "catkin build --no-notify --profile lpcraft",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "\nsource ${ROS_PROJECT_BASE}/devel/setup.sh\n"
+                        "catkin test --profile lpcraft --summary",
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_catkin_tools_plugin_user_command(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin-tools
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    run-before: echo 'hello'
+                    run: echo 'robot'
+                    # run-after: "we shouldn't get anything unless uncommented"
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("noetic", "3", "1")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["python3-catkin-tools", "file", "git"]
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'hello'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'robot'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_colcon_plugin(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: colcon
+                    series: focal
+                    architectures: amd64
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("foxy", "3", "2")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env, ["python3-colcon-common-extensions"]
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi\n"  # noqa:E501
+                        "colcon build --base-paths ${ROS_PROJECT_SRC} --build-base ${ROS_PROJECT_BUILD} --install-base ${ROS_PROJECT_INSTALL} --event-handlers console_direct+",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_colcon_plugin_with_tests(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: colcon
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("foxy", "3", "2")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env,
+                    ["python3-colcon-common-extensions", "file", "git"],
+                ),
+                self._get_ros_before_run_call(expected_env),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi\n"  # noqa:E501
+                        "colcon build --base-paths ${ROS_PROJECT_SRC} --build-base ${ROS_PROJECT_BUILD} --install-base ${ROS_PROJECT_INSTALL} --event-handlers console_direct+",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    [
+                        "bash",
+                        "--noprofile",
+                        "--norc",
+                        "-ec",
+                        "\nsource ${ROS_PROJECT_INSTALL}/setup.sh\n"
+                        "colcon test --base-paths ${ROS_PROJECT_SRC} --build-base ${ROS_PROJECT_BUILD} --install-base ${ROS_PROJECT_INSTALL} --event-handlers console_direct+\n"  # noqa:E501
+                        "colcon test-result --all --verbose --test-result-base ${ROS_PROJECT_BUILD}",  # noqa:E501
+                    ],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
+
+    @patch("lpcraft.commands.run.get_provider")
+    @patch("lpcraft.commands.run.get_host_architecture", return_value="amd64")
+    def test_colcon_plugin_user_command(
+        self, mock_get_host_architecture, mock_get_provider
+    ):
+        launcher = Mock(spec=launch)
+        provider = makeLXDProvider(lxd_launcher=launcher)
+        mock_get_provider.return_value = provider
+        execute_run = launcher.return_value.execute_run
+        execute_run.return_value = subprocess.CompletedProcess([], 0)
+        config = dedent(
+            """
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: colcon
+                    run-tests: True
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    run-before: echo 'hello'
+                    run: echo 'robot'
+                    # run-after: "we shouldn't get anything unless uncommented"
+            """
+        )
+        Path(".launchpad.yaml").write_text(config)
+
+        expected_env = self._get_ros_expected_env("foxy", "3", "2")
+
+        self.run_command("run")
+        self.assertEqual(
+            [
+                call(
+                    ["apt", "update"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                self._get_ros_packages_call(
+                    expected_env,
+                    ["python3-colcon-common-extensions", "file", "git"],
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'hello'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+                call(
+                    ["bash", "--noprofile", "--norc", "-ec", "echo 'robot'"],
+                    cwd=PosixPath("/build/lpcraft/project"),
+                    env=expected_env,
+                    stdout=ANY,
+                    stderr=ANY,
+                ),
+            ],
+            execute_run.call_args_list,
+        )
diff --git a/lpcraft/plugins/plugins.py b/lpcraft/plugins/plugins.py
index d4464ee..be140fc 100644
--- a/lpcraft/plugins/plugins.py
+++ b/lpcraft/plugins/plugins.py
@@ -9,6 +9,9 @@ __all__ = [
     "MiniCondaPlugin",
     "CondaBuildPlugin",
     "GolangPlugin",
+    "CatkinPlugin",
+    "CatkinToolsPlugin",
+    "ColconPlugin",
 ]
 
 import textwrap
@@ -18,6 +21,7 @@ from typing import TYPE_CHECKING, ClassVar, Dict, List, Optional, cast
 import pydantic
 from pydantic import StrictStr
 
+from lpcraft.env import get_managed_environment_project_path
 from lpcraft.plugin import hookimpl
 from lpcraft.plugins import register
 
@@ -451,3 +455,275 @@ class GolangPlugin(BasePlugin):
             export PATH=/usr/lib/go-{version}/bin/:$PATH
             {run_command}"""
         )
+
+
+class RosBasePlugin(BasePlugin):
+    """A base class for ROS-related plugins."""
+
+    _MAP_SERIES_TO_ROSDISTRO = {
+        "xenial": "kinetic",
+        "bionic": "melodic",
+        "focal": "noetic",
+    }
+
+    _MAP_SERIES_TO_ROS2DISTRO = {
+        "bionic": "dashing",
+        "focal": "foxy",
+        "jammy": "humble",
+    }
+
+    class Config(BaseConfig):
+        run_tests: Optional[bool]
+
+    def get_plugin_config(self) -> "RosBasePlugin.Config":
+        return cast(RosBasePlugin.Config, self.config.plugin_config)
+
+    def _get_ros_version(self) -> int:
+        return 0
+
+    def _get_ros_distro(self) -> str:
+        """Get the ROS distro associated to the Ubuntu series in use."""
+        if self._get_ros_version() == 1:
+            return self._MAP_SERIES_TO_ROSDISTRO[self.config.series]
+        elif self._get_ros_version() == 2:
+            return self._MAP_SERIES_TO_ROS2DISTRO[self.config.series]
+        else:
+            raise Exception("Unknown ROS version.")
+
+    def _get_project_path(self) -> Path:
+        """Get the project path."""
+        return get_managed_environment_project_path()
+
+    def _get_build_path(self) -> Path:
+        """Get the out-of-source build path."""
+        return get_managed_environment_project_path().parent / "build"
+
+    def _get_install_path(self) -> Path:
+        """Get the out-of-source install path."""
+        return get_managed_environment_project_path().parent / "install"
+
+    def _get_ros_workspace_activation(self) -> str:
+        """Get the ROS system workspace activation command."""
+        # There should be only one ROS distro installed at any time
+        # therefore let's make use of the wildcard
+        return "if [ -f /opt/ros/*/setup.sh ]; then source /opt/ros/*/setup.sh; fi"  # noqa:E501
+
+    @hookimpl  # type: ignore
+    def lpcraft_set_environment(self) -> dict[str, str | None]:
+        ros_distro = self._get_ros_distro()
+        python_version = (
+            "3" if ros_distro not in ["kinetic", "melodic"] else "2"
+        )
+        return {
+            "ROS_DISTRO": ros_distro,
+            "ROS_PROJECT_BASE": str(self._get_project_path().parent),
+            "ROS_PROJECT_SRC": str(self._get_project_path()),
+            "ROS_PROJECT_BUILD": str(self._get_build_path()),
+            "ROS_PROJECT_INSTALL": str(self._get_install_path()),
+            "ROS_PYTHON_VERSION": python_version,
+            "ROS_VERSION": f"{self._get_ros_version()}",
+        }
+
+    @hookimpl  # type: ignore
+    def lpcraft_install_packages(self) -> list[str]:
+        return ["build-essential", "cmake", "g++", "python3-rosdep"]
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_before_run(self) -> str:
+        return self._get_ros_workspace_activation() + textwrap.dedent(
+            """
+            if [ ! -f /etc/ros/rosdep/sources.list.d/20-default.list ]; then
+            rosdep init
+            fi
+            rosdep update --rosdistro ${ROS_DISTRO}
+            rosdep install -i -y --rosdistro ${ROS_DISTRO} --from-paths ."""
+        )
+
+
+@register(name="catkin")
+class CatkinPlugin(RosBasePlugin):
+    """Installs ROS dependencies, builds and (optionally) tests with catkin.
+
+    Usage:
+        In `.launchpad.yaml`, create the following structure.
+
+        .. code-block:: yaml
+
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin
+                    run-tests: True # optional
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    package-repositories:
+                        - components: [main]
+                          formats: [deb]
+                          suites: [focal]
+                          type: apt
+                          url: http://packages.ros.org/ros/ubuntu
+                          trusted: True
+
+    Please note that the ROS repository must be
+    set up in `package-repositories`.
+    """
+
+    def _get_ros_version(self) -> int:
+        return 1
+
+    @hookimpl  # type: ignore
+    def lpcraft_install_packages(self) -> list[str]:
+        # XXX artivis 2022-12-9: mypy is struggling with the super() call
+        packages: list[str] = super().lpcraft_install_packages() + [
+            # "ros-${ROS_DISTRO}-catkin"
+            f"ros-{self._get_ros_distro()}-catkin"
+        ]
+        return packages
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_run(self) -> str:
+        return self._get_ros_workspace_activation() + textwrap.dedent(
+            """
+            catkin_make_isolated --source-space ${ROS_PROJECT_SRC} --directory ${ROS_PROJECT_BASE}"""  # noqa:E501
+        )
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_after_run(self) -> str:
+        if not self.get_plugin_config().run_tests or self.config.run:
+            return ""
+
+        return textwrap.dedent(
+            """
+            source ${ROS_PROJECT_BASE}/devel_isolated/setup.sh
+            catkin_make_isolated --source-space ${ROS_PROJECT_SRC} --directory ${ROS_PROJECT_BASE} --force-cmake --catkin-make-args run_tests
+            catkin_test_results --verbose ${ROS_PROJECT_BASE}/build_isolated/"""  # noqa:E501
+        )
+
+
+@register(name="catkin-tools")
+class CatkinToolsPlugin(RosBasePlugin):
+    """Installs ROS dependencies, builds and (optionally) tests with catkin-tools.
+
+    Usage:
+        In `.launchpad.yaml`, create the following structure.
+
+        .. code-block:: yaml
+
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: catkin-tools
+                    run-tests: True # optional
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    package-repositories:
+                        - components: [main]
+                          formats: [deb]
+                          suites: [focal]
+                          type: apt
+                          url: http://packages.ros.org/ros/ubuntu
+                          trusted: True
+
+    Please note that the ROS repository must be
+    set up in `package-repositories`.
+    """
+
+    def _get_ros_version(self) -> int:
+        return 1
+
+    @hookimpl  # type: ignore
+    def lpcraft_install_packages(self) -> list[str]:
+        # XXX artivis 2022-12-9: mypy is struggling with the super() call
+        packages: list[str] = super().lpcraft_install_packages() + [
+            "python3-catkin-tools"
+        ]
+        return packages
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_run(self) -> str:
+        return self._get_ros_workspace_activation() + textwrap.dedent(
+            """
+            catkin init --workspace ${ROS_PROJECT_BASE}
+            catkin profile add --force lpcraft
+            catkin config --profile lpcraft --install --source-space ${ROS_PROJECT_SRC} --build-space ${ROS_PROJECT_BUILD} --install-space ${ROS_PROJECT_INSTALL}
+            catkin build --no-notify --profile lpcraft"""  # noqa:E501
+        )
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_after_run(self) -> str:
+        if not self.get_plugin_config().run_tests or self.config.run:
+            return ""
+
+        return textwrap.dedent(
+            """
+            source ${ROS_PROJECT_BASE}/devel/setup.sh
+            catkin test --profile lpcraft --summary"""
+        )
+
+
+@register(name="colcon")
+class ColconPlugin(RosBasePlugin):
+    """Installs ROS dependencies, builds and (optionally) tests with colcon.
+
+    Usage:
+        In `.launchpad.yaml`, create the following structure.
+
+        .. code-block:: yaml
+
+            pipeline:
+                - build
+
+            jobs:
+                build:
+                    plugin: colcon
+                    run-tests: True # optional
+                    series: focal
+                    architectures: amd64
+                    packages: [file, git]
+                    package-repositories:
+                        - components: [main]
+                          formats: [deb]
+                          suites: [focal]
+                          type: apt
+                          url: http://repo.ros2.org/ubuntu/main/
+                          trusted: True
+
+    Please note that the ROS 2 repository must be
+    set up in `package-repositories`.
+    """
+
+    def _get_ros_version(self) -> int:
+        return 2
+
+    @hookimpl  # type: ignore
+    def lpcraft_install_packages(self) -> list[str]:
+        # XXX artivis 2022-12-9: mypy is struggling with the super() call
+        packages: list[str] = super().lpcraft_install_packages() + [
+            "python3-colcon-common-extensions"
+        ]
+        return packages
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_run(self) -> str:
+        return self._get_ros_workspace_activation() + textwrap.dedent(
+            """
+            colcon build --base-paths ${ROS_PROJECT_SRC} --build-base ${ROS_PROJECT_BUILD} --install-base ${ROS_PROJECT_INSTALL} --event-handlers console_direct+"""  # noqa:E501
+        )
+
+    @hookimpl  # type: ignore
+    def lpcraft_execute_after_run(self) -> str:
+        if not self.get_plugin_config().run_tests or self.config.run:
+            return ""
+
+        return textwrap.dedent(
+            """
+            source ${ROS_PROJECT_INSTALL}/setup.sh
+            colcon test --base-paths ${ROS_PROJECT_SRC} --build-base ${ROS_PROJECT_BUILD} --install-base ${ROS_PROJECT_INSTALL} --event-handlers console_direct+
+            colcon test-result --all --verbose --test-result-base ${ROS_PROJECT_BUILD}"""  # noqa:E501
+        )

Follow ups