Repository: canonical/snapcraft

Issue:
new ament plugin (ros2)
[design](https://forum.snapcraft.io/t/proposal-ros-2-support/1087)


Discussion:

Resolving patch:
diff --git a/snapcraft/file_utils.py b/snapcraft/file_utils.py
--- a/snapcraft/file_utils.py
+++ b/snapcraft/file_utils.py
@@ -127,13 +127,13 @@ def link_or_copy(source, destination, follow_symlinks=False):
 
 def link_or_copy_tree(source_tree, destination_tree,
                       copy_function=link_or_copy):
-    """Copy a source tree into a destination, hard-linking if possile.
+    """Copy a source tree into a destination, hard-linking if possible.
 
     :param str source_tree: Source directory to be copied.
     :param str destination_tree: Destination directory. If this directory
                                  already exists, the files in `source_tree`
                                  will take precedence.
-    :param str boundary: Filesystem boundary no symlinks are allowed to cross.
+    :param callable copy_function: Callable that actually copies.
     """
 
     if not os.path.isdir(source_tree):
diff --git a/snapcraft/plugins/ament.py b/snapcraft/plugins/ament.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/plugins/ament.py
@@ -0,0 +1,197 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+"""The ament plugin is useful for building ROS2 parts.
+
+This plugin uses the common plugin keywords as well as those for "sources".
+For more information check the 'plugins' topic for the former and the
+'sources' topic for the latter.
+
+Additionally, this plugin uses the following plugin-specific keywords:
+
+    - version:
+      (string)
+      The ROS2 version required by this system. This relates to the ros2 tags.
+      Defaults to 'release-beta3'.
+"""
+
+import logging
+import os
+import re
+import textwrap
+
+import snapcraft
+from . import _ros
+
+logger = logging.getLogger(__name__)
+
+
+class AmentPlugin(snapcraft.BasePlugin):
+
+    @property
+    def PLUGIN_STAGE_SOURCES(self):
+        """Specify the sources used for stage-packages.
+
+        Note the ROS archive in particular. Build-packages do not come from
+        here, but stage-packages do.
+        """
+
+        return """
+deb http://packages.ros.org/ros/ubuntu/ {0} main
+deb http://${{prefix}}.ubuntu.com/${{suffix}}/ {0} main universe
+deb http://${{prefix}}.ubuntu.com/${{suffix}}/ {0}-updates main universe
+deb http://${{prefix}}.ubuntu.com/${{suffix}}/ {0}-security main universe
+deb http://${{security}}.ubuntu.com/${{suffix}} {0}-security main universe
+""".format('xenial')
+
+    @classmethod
+    def schema(cls):
+        """Specify the jsonschema of the supported options.
+
+        Snapcraft will use this schema to validate the YAML when this plugin is
+        used.
+        """
+
+        schema = super().schema()
+
+        schema['properties']['version'] = {
+            'type': 'string',
+            'default': 'release-beta3'
+        }
+
+        return schema
+
+    @classmethod
+    def get_pull_properties(cls):
+        """Inform Snapcraft of the properties associated with pulling.
+
+        If these change in the YAML, Snapcraft will consider the pull step
+        dirty.
+        """
+        return ['version']
+
+    def __init__(self, name, options, project):
+        """Initialize a new AmentPlugin.
+
+        :param str name: The name of this part
+        :param object options: Objectified version of the properties for this
+                               part
+        :param project: Instance of ProjectOptions for project-wide settings
+        :type project: snapcraft.ProjectOptions
+        """
+        super().__init__(name, options, project)
+
+        # FIXME: Remove this warning once the plugin (and indeed ROS2) is
+        # considered stable
+        logger.warn('The ament plugin is currently in beta, its API may '
+                    'break. Use at your own risk')
+
+        self._bootstrap_dir = os.path.join(self.partdir, 'bootstrap')
+
+        self._bootstrapper = _ros.ros2.Bootstrapper(
+            version=self.options.version,
+            bootstrap_path=self._bootstrap_dir,
+            ubuntu_sources=self.PLUGIN_STAGE_SOURCES,
+            project=self.project)
+
+        self.stage_packages.extend(self._bootstrapper.get_stage_packages())
+        self.build_packages.extend(self._bootstrapper.get_build_packages())
+
+    def pull(self):
+        """Pull the provided source, as well as the ROS2 underlay."""
+
+        super().pull()
+
+        # Pull ros2 underlay
+        self._bootstrapper.pull()
+
+    def clean_pull(self):
+        """Clean the fetched source and ROS2 underlay."""
+
+        super().clean_pull()
+
+        self._bootstrapper.clean()
+
+    def build(self):
+        """Bootstrap the ROS2 underlay, then use it to build the source.
+
+        This step can take a while.
+        """
+
+        super().build()
+
+        self._prepare_build()
+
+        self.run([
+            'ament', 'build', self.sourcedir, '--build-space', self.builddir,
+            '--install-space', self.installdir, '--cmake-args',
+            '-DCMAKE_BUILD_TYPE=Release'], env=self._build_env())
+
+        self._finish_build()
+
+    def _prepare_build(self):
+        # Bootstrap ros2, and migrate it into our installdir
+        ros2_install_dir = self._bootstrapper.build()
+        snapcraft.file_utils.link_or_copy_tree(
+            ros2_install_dir, self.installdir)
+
+        # Also rewrite the prefixes to point to the part installdir rather
+        # than the bootstrap area. Otherwise Ament embeds absolute paths which
+        # makes the resulting code non-relocatable.
+        snapcraft.file_utils.replace_in_file(
+            self.installdir, re.compile(r''),
+            re.compile(r'\${AMENT_CURRENT_PREFIX:=.*}'),
+            '${{AMENT_CURRENT_PREFIX:={}}}'.format(self.installdir))
+
+    def _finish_build(self):
+        # Set the AMENT_CURRENT_PREFIX throughout to a sensible default,
+        # removing part-specific directories that will clash with other parts.
+        snapcraft.file_utils.replace_in_file(
+            self.installdir, re.compile(r''),
+            re.compile(r'\${AMENT_CURRENT_PREFIX:=.*}'),
+            '${AMENT_CURRENT_PREFIX:=$SNAP}')
+
+    def env(self, root):
+        """Runtime environment for ROS2 apps."""
+
+        env = []
+
+        env.append('PYTHONUSERBASE="{}"'.format(root))
+        env.append('PYTHONPATH="{}:{}${{PYTHONPATH:+:$PYTHONPATH}}"'.format(
+            os.path.join(root, 'usr', 'lib', 'python3', 'dist-packages'),
+            os.path.join(root, 'lib', 'python3', 'dist-packages')))
+
+        # Each of these lines is prepended with an `export` when the
+        # environment is actually generated. In order to inject real shell code
+        # we have to hack it in by appending it on the end of an item already
+        # in the environment. FIXME: There should be a better way to do this.
+        env[-1] = env[-1] + '\n\n' + self._source_setup_sh(root)
+
+        return env
+
+    def _build_env(self):
+        env = os.environ.copy()
+        env['PYTHONPATH'] = os.path.join(
+            os.path.sep, 'usr', 'lib', 'python3', 'dist-packages')
+        return env
+
+    def _source_setup_sh(self, root):
+        return textwrap.dedent('''
+            if [ -f {ros_setup} ]; then
+                . {ros_setup}
+            fi
+        ''').format(
+            ros_setup=os.path.join(root, 'setup.sh'))


Test patch:
diff --git a/snapcraft/tests/integration/snapd/test_ament_snap.py b/snapcraft/tests/integration/snapd/test_ament_snap.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snapd/test_ament_snap.py
@@ -0,0 +1,43 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import subprocess
+
+from testtools.matchers import Contains
+
+from snapcraft.tests import (
+    integration,
+    fixture_setup,
+    skip,
+)
+
+
+class AmentTestCase(integration.SnapdIntegrationTestCase):
+
+    slow_test = True
+
+    @skip.skip_unless_codename(
+        'xenial', 'Anything later than xenial will die with NO_PUBKEY')
+    def test_ament_support(self):
+        self.useFixture(fixture_setup.WithoutSnapInstalled('ros2-example'))
+        self.run_snapcraft(project_dir='ros2')
+        self.install_snap()
+
+        self.assertThat(
+            subprocess.check_output(
+                ['ros2-example.launch-project'],
+                universal_newlines=True, stderr=subprocess.STDOUT),
+            Contains('I heard: Hello world'))
diff --git a/snapcraft/tests/integration/snaps/ros2/snap/snapcraft.yaml b/snapcraft/tests/integration/snaps/ros2/snap/snapcraft.yaml
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/snap/snapcraft.yaml
@@ -0,0 +1,21 @@
+name: ros2-example
+version: '0.1'
+summary: ROS2 Example
+description: |
+  A ROS2 workspace containing a talker and a listener.
+
+grade: stable
+confinement: strict
+
+apps:
+  launch-project:
+    command: launch "$(ros2 pkg prefix listener_py)/share/listener_py/launch/talk_and_listen.py"
+    plugs: [network]
+    environment:
+      # Required to get interleaved stdout, see
+      # https://discourse.ros.org/t/ros2-launch-not-interleaving-stdout
+      PYTHONUNBUFFERED: 1
+
+parts:
+  my-part:
+    plugin: ament
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/launch/talk_and_listen.py b/snapcraft/tests/integration/snaps/ros2/src/listener_py/launch/talk_and_listen.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/listener_py/launch/talk_and_listen.py
@@ -0,0 +1,17 @@
+from ros2run.api import get_executable_path
+
+
+def launch(launch_descriptor, argv):
+    ld = launch_descriptor
+    ld.add_process(
+        cmd=[get_executable_path(
+            package_name='talker_cpp', executable_name='talker')],
+        name='talker',
+    )
+    ld.add_process(
+        cmd=[get_executable_path(
+            package_name='listener_py', executable_name='listener')],
+        name='listener',
+    )
+
+    return ld
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/nodes/__init__.py b/snapcraft/tests/integration/snaps/ros2/src/listener_py/nodes/__init__.py
new file mode 100644
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/nodes/listener.py b/snapcraft/tests/integration/snaps/ros2/src/listener_py/nodes/listener.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/listener_py/nodes/listener.py
@@ -0,0 +1,28 @@
+import rclpy
+from std_msgs.msg import String
+
+
+class ListenerNode:
+
+    def __init__(self, node_name):
+        self._node = rclpy.Node(node_name)
+        self._node.create_subscription(String, 'chatter', self.callback)
+
+    def callback(self, message):
+        print('I heard: {}'.format(message.data))
+        self._node.destroy_node()
+        rclpy.shutdown()
+
+    def run(self):
+        rclpy.spin(self._node)
+
+
+def main():
+    rclpy.init()
+
+    node = ListenerNode('listener')
+    node.run()
+
+
+if __name__ == '__main__':
+    main()
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/package.xml b/snapcraft/tests/integration/snaps/ros2/src/listener_py/package.xml
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/listener_py/package.xml
@@ -0,0 +1,16 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format2.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="2">
+  <name>listener_py</name>
+  <version>0.0.0</version>
+  <description>The listener_py package</description>
+  <maintainer email="me@example.com">me</maintainer>
+  <license>GPLv3</license>
+
+  <exec_depend>rclpy</exec_depend>
+  <exec_depend>std_msgs</exec_depend>
+
+  <export>
+    <build_type>ament_python</build_type>
+  </export>
+</package>
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.cfg b/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.cfg
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.cfg
@@ -0,0 +1,4 @@
+[develop]
+script-dir=$base/lib/listener_py
+[install]
+install-scripts=$base/lib/listener_py
diff --git a/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.py b/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/listener_py/setup.py
@@ -0,0 +1,22 @@
+import os
+from setuptools import find_packages
+from setuptools import setup
+
+package_name = 'listener_py'
+
+setup(
+    name=package_name,
+    version='0.0.0',
+    packages=find_packages(),
+    data_files=[
+        (os.path.join('share', package_name), ['package.xml']),
+        (os.path.join('share', package_name, 'launch'),
+         ['launch/talk_and_listen.py']),
+    ],
+    install_requires=['setuptools'],
+    entry_points={
+        'console_scripts': [
+            'listener = nodes.listener:main',
+        ],
+    },
+)
diff --git a/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/CMakeLists.txt b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/CMakeLists.txt
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/CMakeLists.txt
@@ -0,0 +1,17 @@
+cmake_minimum_required(VERSION 3.5)
+
+project(talker_cpp)
+
+find_package(ament_cmake REQUIRED)
+find_package(rclcpp REQUIRED)
+find_package(std_msgs REQUIRED)
+
+add_definitions("-std=c++11 -Wall -Wextra")
+
+add_executable(talker src/talker.cpp)
+ament_target_dependencies(talker
+  "rclcpp"
+  "std_msgs")
+install(TARGETS talker DESTINATION lib/${PROJECT_NAME})
+
+ament_package()
diff --git a/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/package.xml b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/package.xml
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/package.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0"?>
+<?xml-model href="http://download.ros.org/schema/package_format2.xsd" schematypens="http://www.w3.org/2001/XMLSchema"?>
+<package format="2">
+  <name>talker_cpp</name>
+  <version>0.0.0</version>
+  <description>The talker_cpp package</description>
+  <maintainer email="me@example.com">me</maintainer>
+  <license>GPLv3</license>
+
+  <buildtool_depend>ament_cmake</buildtool_depend>
+
+  <build_depend>rclcpp</build_depend>
+  <build_depend>std_msgs</build_depend>
+
+  <exec_depend>rclcpp</exec_depend>
+  <exec_depend>std_msgs</exec_depend>
+
+  <export>
+    <build_type>ament_cmake</build_type>
+  </export>
+</package>
diff --git a/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/src/talker.cpp b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/src/talker.cpp
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/integration/snaps/ros2/src/talker_cpp/src/talker.cpp
@@ -0,0 +1,36 @@
+#include <iostream>
+#include <sstream>
+
+#include <rclcpp/rclcpp.hpp>
+#include <std_msgs/msg/string.hpp>
+
+int main(int argc, char * argv[])
+{
+  rclcpp::init(argc, argv);
+
+  auto node = rclcpp::node::Node::make_shared("talker");
+
+  auto publisher = node->create_publisher<std_msgs::msg::String>("chatter", rmw_qos_profile_default);
+
+  rclcpp::WallRate loopRate(1);
+
+  int count = 0;
+  while (rclcpp::ok())
+  {
+    auto message = std::make_shared<std_msgs::msg::String>();
+
+    std::stringstream stream;
+    stream << "Hello world " << count++;
+    message->data = stream.str();
+
+    std::cout << message->data << std::endl;
+
+    publisher->publish(message);
+
+    rclcpp::spin_some(node);
+
+    loopRate.sleep();
+  }
+
+  return 0;
+}
diff --git a/snapcraft/tests/unit/commands/test_list_plugins.py b/snapcraft/tests/unit/commands/test_list_plugins.py
--- a/snapcraft/tests/unit/commands/test_list_plugins.py
+++ b/snapcraft/tests/unit/commands/test_list_plugins.py
@@ -28,12 +28,12 @@ class ListPluginsCommandTestCase(CommandBaseTestCase):
 
     # plugin list when wrapper at MAX_CHARACTERS_WRAP
     default_plugin_output = (
-        'ant        catkin-tools  dotnet  godeps  jdk      '
-        'kernel  meson   plainbox-provider  python3  rust         waf\n'
-        'autotools  cmake         dump    gradle  jhbuild  '
-        'make    nil     python             qmake    scons      \n'
-        'catkin     copy          go      gulp    kbuild   '
-        'maven   nodejs  python2            ruby     tar-content\n'
+        'ament      catkin        copy    go      gulp     kbuild  maven  '
+        'nodejs             python2  ruby   tar-content\n'
+        'ant        catkin-tools  dotnet  godeps  jdk      kernel  meson  '
+        'plainbox-provider  python3  rust   waf        \n'
+        'autotools  cmake         dump    gradle  jhbuild  make    nil    '
+        'python             qmake    scons\n'
     )
 
     def test_list_plugins_non_tty(self):
@@ -62,6 +62,7 @@ def test_list_plugins_small_terminal(self):
         self.useFixture(fake_terminal)
 
         expected_output = (
+            'ament         dump     kernel             python2    \n'
             'ant           go       make               python3    \n'
             'autotools     godeps   maven              qmake      \n'
             'catkin        gradle   meson              ruby       \n'
@@ -69,7 +70,6 @@ def test_list_plugins_small_terminal(self):
             'cmake         jdk      nodejs             scons      \n'
             'copy          jhbuild  plainbox-provider  tar-content\n'
             'dotnet        kbuild   python             waf        \n'
-            'dump          kernel   python2          \n'
 
         )
 
diff --git a/snapcraft/tests/unit/plugins/test_ament.py b/snapcraft/tests/unit/plugins/test_ament.py
new file mode 100644
--- /dev/null
+++ b/snapcraft/tests/unit/plugins/test_ament.py
@@ -0,0 +1,211 @@
+# -*- Mode:Python; indent-tabs-mode:nil; tab-width:4 -*-
+#
+# Copyright (C) 2017 Canonical Ltd
+#
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 3 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program.  If not, see <http://www.gnu.org/licenses/>.
+
+import os
+
+from unittest import mock
+from testtools.matchers import (
+    Contains,
+    Equals,
+    FileExists,
+    FileContains,
+    HasLength,
+)
+
+import snapcraft
+from snapcraft.tests import unit
+from snapcraft.plugins import ament
+
+
+class AmentPluginTestCase(unit.TestCase):
+
+    def setUp(self):
+        super().setUp()
+
+        class props:
+            version = 'release-beta3'
+
+        self.properties = props()
+        self.project = snapcraft.ProjectOptions()
+
+        patcher = mock.patch('snapcraft.plugins._ros.ros2.Bootstrapper')
+        self.bootstrapper_mock = patcher.start()
+        self.addCleanup(patcher.stop)
+
+    def test_schema(self):
+        schema = ament.AmentPlugin.schema()
+
+        properties = schema['properties']
+        self.assertThat(properties, HasLength(1))
+        self.assertThat(properties, Contains('version'))
+
+    def test_schema_version(self):
+        schema = ament.AmentPlugin.schema()
+
+        # Check version property
+        rosdistro = schema['properties']['version']
+        expected = ('type', 'default')
+        self.assertThat(rosdistro, HasLength(len(expected)))
+        for prop in expected:
+            self.assertThat(rosdistro, Contains(prop))
+        self.assertThat(rosdistro['type'], Equals('string'))
+        self.assertThat(rosdistro['default'], Equals('release-beta3'))
+
+    def test_pull_properties(self):
+        expected_pull_properties = ['version']
+        actual_pull_properties = ament.AmentPlugin.get_pull_properties()
+
+        self.assertThat(actual_pull_properties,
+                        HasLength(len(expected_pull_properties)))
+
+        for property in expected_pull_properties:
+            self.assertIn(property, actual_pull_properties)
+
+    def test_sources_include_ros_archive(self):
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        self.assertThat(plugin.PLUGIN_STAGE_SOURCES, Contains(
+            'deb http://packages.ros.org/ros/ubuntu/ xenial main'))
+
+    def test_bootstrap_build_packages_are_included(self):
+        self.bootstrapper_mock.return_value.get_build_packages.return_value = [
+            'foo']
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+        self.assertThat(plugin.build_packages, Contains('foo'))
+
+    def test_bootstrap_stage_packages_are_included(self):
+        self.bootstrapper_mock.return_value.get_stage_packages.return_value = [
+            'foo']
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+        self.assertThat(plugin.stage_packages, Contains('foo'))
+
+    def test_pull_fetches_ros2_underlay(self):
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        plugin.pull()
+
+        self.bootstrapper_mock.return_value.pull.assert_called_once_with()
+        self.bootstrapper_mock.return_value.build.assert_not_called()
+
+    def test_clean_pull_cleans_bootstrap(self):
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        plugin.clean_pull()
+
+        mock_clean = self.bootstrapper_mock.return_value.clean
+        mock_clean.assert_called_once_with()
+
+    @mock.patch('subprocess.check_call')
+    def test_build_builds_ros2_underlay(self, mock_check_call):
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        self.bootstrapper_mock.return_value.build.return_value = 'bootstrap'
+        os.mkdir('bootstrap')
+        with open(os.path.join('bootstrap', 'test-file'), 'w') as f:
+            f.write('hello')
+
+        plugin.build()
+
+        self.bootstrapper_mock.return_value.pull.assert_not_called()
+        self.bootstrapper_mock.return_value.build.assert_called_once_with()
+
+        # Assert that the bootstrap dir was copied into the installdir
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'), FileExists())
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'),
+            FileContains('hello'))
+
+        class check_env():
+            def __eq__(self, env):
+                return (
+                    env['PYTHONPATH'] == os.path.join(
+                        os.path.sep, 'usr', 'lib', 'python3', 'dist-packages'))
+
+        # Verify that the source space was built as expected, and that the
+        # system's PYTHONPATH was included while building
+        mock_check_call.assert_called_once_with([
+            mock.ANY, mock.ANY, 'ament', 'build', plugin.sourcedir,
+            '--build-space', plugin.builddir, '--install-space',
+            plugin.installdir, '--cmake-args', '-DCMAKE_BUILD_TYPE=Release'],
+            cwd=mock.ANY, env=check_env())
+
+    def test_prepare_build_rewrites_ament_current_prefix(self):
+        """Make sure AMENT_CURRENT_PREFIX is rewritten to be the installdir
+
+        Otherwise ament embeds absolute paths in there, which results in a
+        non-relocatable snap.
+        """
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        self.bootstrapper_mock.return_value.build.return_value = 'bootstrap'
+        os.mkdir('bootstrap')
+        with open(os.path.join('bootstrap', 'test-file'), 'w') as f:
+            f.write('${AMENT_CURRENT_PREFIX:=bootstrap}')
+
+        plugin._prepare_build()
+
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'), FileExists())
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'),
+            FileContains('${{AMENT_CURRENT_PREFIX:={}}}'.format(
+                plugin.installdir)))
+
+    def test_finish_build_rewrites_ament_current_prefix(self):
+        """Make sure AMENT_CURRENT_PREFIX is rewritten to $SNAP
+
+        Otherwise ament keeps an absolute path in there, requiring us to define
+        it at runtime to a valid location. Also, without this, multiple parts
+        would clash.
+        """
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        self.bootstrapper_mock.return_value.build.return_value = 'bootstrap'
+        os.mkdir('bootstrap')
+        with open(os.path.join('bootstrap', 'test-file'), 'w') as f:
+            f.write('${AMENT_CURRENT_PREFIX:=bootstrap}')
+
+        plugin._prepare_build()
+        plugin._finish_build()
+
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'), FileExists())
+        self.assertThat(
+            os.path.join(plugin.installdir, 'test-file'),
+            FileContains('${AMENT_CURRENT_PREFIX:=$SNAP}'))
+
+    def test_environment(self):
+        plugin = ament.AmentPlugin('test-part', self.properties, self.project)
+
+        python_path = os.path.join(
+            plugin.installdir, 'usr', 'lib', 'python2.7', 'dist-packages')
+        os.makedirs(python_path)
+
+        # Joining and re-splitting to get hacked script in there as well
+        environment = '\n'.join(plugin.env(plugin.installdir)).split('\n')
+
+        self.assertThat(environment, Contains(
+            'PYTHONUSERBASE="{}"'.format(plugin.installdir)))
+        self.assertThat(environment, Contains(
+            'PYTHONPATH="{}:{}${{PYTHONPATH:+:$PYTHONPATH}}"'.format(
+                os.path.join(
+                    plugin.installdir, 'usr', 'lib', 'python3',
+                    'dist-packages'),
+                os.path.join(
+                    plugin.installdir, 'lib', 'python3', 'dist-packages'))))
+        self.assertThat(environment, Contains(
+            '    . {}/setup.sh'.format(plugin.installdir)))