diff --git a/.devcontainer/prepare-local-features.sh b/.devcontainer/prepare-local-features.sh index e1ed0c0..f7142bf 100755 --- a/.devcontainer/prepare-local-features.sh +++ b/.devcontainer/prepare-local-features.sh @@ -6,7 +6,8 @@ repo_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" local_features_dir="${repo_root}/.devcontainer/local-features" mkdir -p "${local_features_dir}" -rm -rf "${local_features_dir}/linux-x11-forwarding" "${local_features_dir}/x11" "${local_features_dir}/wayland" +rm -rf "${local_features_dir}/linux-x11-forwarding" "${local_features_dir}/ros2" "${local_features_dir}/x11" "${local_features_dir}/wayland" cp -R "${repo_root}/features/src/linux-x11-forwarding" "${local_features_dir}/linux-x11-forwarding" +cp -R "${repo_root}/features/src/ros2" "${local_features_dir}/ros2" echo "Prepared local smoke-test features in ${local_features_dir}" diff --git a/.devcontainer/ros2-feature-smoke/devcontainer.json b/.devcontainer/ros2-feature-smoke/devcontainer.json new file mode 100644 index 0000000..52bb264 --- /dev/null +++ b/.devcontainer/ros2-feature-smoke/devcontainer.json @@ -0,0 +1,8 @@ +{ + "name": "ros2-feature-smoke", + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "../local-features/ros2": {} + }, + "postCreateCommand": "bash -lc 'ros2 pkg prefix rviz2 >/dev/null && command -v colcon >/dev/null && command -v rosdep >/dev/null && command -v vcs >/dev/null'" +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json index bb51188..5b8401c 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -41,6 +41,71 @@ ], "problemMatcher": [] }, + { + "label": "Prepare Local Features", + "type": "process", + "command": "bash", + "args": [ + ".devcontainer/prepare-local-features.sh" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "Local Smoke Test ROS 2 Feature", + "dependsOrder": "sequence", + "dependsOn": [ + "Prepare Local Features" + ], + "type": "process", + "command": "devcontainer", + "args": [ + "up", + "--workspace-folder", + "${workspaceFolder}", + "--config", + "${workspaceFolder}/.devcontainer/ros2-feature-smoke/devcontainer.json" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "Test ROS 2 Feature", + "type": "process", + "command": "devcontainer", + "args": [ + "features", + "test", + "--features", + "ros2", + "--project-folder", + "./features" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "problemMatcher": [] + }, + { + "label": "Test All Features", + "type": "process", + "command": "bash", + "args": [ + "features/test/test_all.sh" + ], + "options": { + "cwd": "${workspaceFolder}" + }, + "group": { + "kind": "test", + "isDefault": true + }, + "problemMatcher": [] + }, { "label": "Generate Feature Docs", "type": "process", diff --git a/README.md b/README.md index ff5e7c3..cb70f11 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,11 @@ Devcontainer templates repository focused on robotics and simulation. - `.devcontainer/gz-smoke/devcontainer.json` - `.devcontainer/linux-x11-forwarding-smoke/devcontainer.json` +- `.devcontainer/ros2-feature-smoke/devcontainer.json` Before using the samples locally, run `bash .devcontainer/prepare-local-features.sh` so `devcontainer up` can resolve -the unpublished Feature. +the unpublished Features. On classic Xorg hosts where the authority cookie lives outside `XDG_RUNTIME_DIR`, add a bind mount to `/tmp/devcontainer-xauthority-host`. diff --git a/features/src/ros2/NOTES.md b/features/src/ros2/NOTES.md new file mode 100644 index 0000000..67fefc0 --- /dev/null +++ b/features/src/ros2/NOTES.md @@ -0,0 +1,32 @@ +## Supported Ubuntu releases + +ROS 2 deb packages target a specific Ubuntu release. Choose a compatible base +image for the selected `distro`. The default `auto` setting selects: + +| ROS 2 distribution | Ubuntu release | +|---|---| +| Humble | 22.04 (Jammy) | +| Jazzy | 24.04 (Noble) | +| Kilted | 24.04 (Noble) | +| Lyrical | 26.04 (Resolute) | + +The installation stops with an explanatory error when the selected ROS 2 and +Ubuntu releases are incompatible. On Ubuntu 24.04, `auto` selects Jazzy because +it is the long-term ROS 2 release; select Kilted explicitly if desired. + +The `package` option defaults to the GUI-oriented `desktop` installation. Select +`ros-base` for a smaller installation. In both cases, the Feature installs +`ros-dev-tools`, including common ROS development commands such as `colcon`, +`rosdep`, and `vcs`. + +## Shell and workspace setup + +The ROS installation is sourced automatically for the existing container user. +If the configured `workspace` contains `install/setup.sh`, that overlay is +sourced afterward. The Feature does not create or replace the container user. + +ROS 2 packages installed from debs use Ubuntu's system Python. Conda or a +separately installed Python interpreter may be incompatible with those packages. + +The Feature's ROS 2-to-Ubuntu compatibility data is stored in +`distributions.json` and packaged with the Feature. diff --git a/features/src/ros2/README.md b/features/src/ros2/README.md new file mode 100644 index 0000000..9c274df --- /dev/null +++ b/features/src/ros2/README.md @@ -0,0 +1,58 @@ + +# ROS 2 (ros2) + +Install ROS 2 and the tools needed to develop ROS packages. + +## Example Usage + +```json +"features": { + "ghcr.io/althack/devcontainers/ros2:0": {} +} +``` + +## Options + +| Options Id | Description | Type | Default Value | +|-----|-----|-----|-----| +| distro | ROS 2 distribution to install. By default, select the recommended distribution for the container's Ubuntu release. | string | auto | +| package | ROS 2 metapackage to install. | string | desktop | +| workspace | Workspace whose install/setup.sh file should be sourced when present. | string | ${containerWorkspaceFolder} | + +## Supported Ubuntu releases + +ROS 2 deb packages target a specific Ubuntu release. Choose a compatible base +image for the selected `distro`. The default `auto` setting selects: + +| ROS 2 distribution | Ubuntu release | +|---|---| +| Humble | 22.04 (Jammy) | +| Jazzy | 24.04 (Noble) | +| Kilted | 24.04 (Noble) | +| Lyrical | 26.04 (Resolute) | + +The installation stops with an explanatory error when the selected ROS 2 and +Ubuntu releases are incompatible. On Ubuntu 24.04, `auto` selects Jazzy because +it is the long-term ROS 2 release; select Kilted explicitly if desired. + +The `package` option defaults to the GUI-oriented `desktop` installation. Select +`ros-base` for a smaller installation. In both cases, the Feature installs +`ros-dev-tools`, including common ROS development commands such as `colcon`, +`rosdep`, and `vcs`. + +## Shell and workspace setup + +The ROS installation is sourced automatically for the existing container user. +If the configured `workspace` contains `install/setup.sh`, that overlay is +sourced afterward. The Feature does not create or replace the container user. + +ROS 2 packages installed from debs use Ubuntu's system Python. Conda or a +separately installed Python interpreter may be incompatible with those packages. + +The Feature's ROS 2-to-Ubuntu compatibility data is stored in +`distributions.json` and packaged with the Feature. + + +--- + +_Note: This file was auto-generated from the [devcontainer-feature.json](https://github.com/althack/devcontainers/blob/main/features/src/ros2/devcontainer-feature.json). Add additional notes to a `NOTES.md`._ diff --git a/features/src/ros2/devcontainer-feature.json b/features/src/ros2/devcontainer-feature.json new file mode 100644 index 0000000..9b7ab44 --- /dev/null +++ b/features/src/ros2/devcontainer-feature.json @@ -0,0 +1,39 @@ +{ + "id": "ros2", + "version": "0.0.0", + "name": "ROS 2", + "description": "Install ROS 2 and the tools needed to develop ROS packages.", + "documentationURL": "https://github.com/althack/devcontainers/tree/main/features/src/ros2", + "licenseURL": "https://github.com/althack/devcontainers/blob/main/LICENSE", + "options": { + "distro": { + "type": "string", + "proposals": [ + "auto", + "humble", + "jazzy", + "kilted", + "lyrical" + ], + "default": "auto", + "description": "ROS 2 distribution to install. By default, select the recommended distribution for the container's Ubuntu release." + }, + "package": { + "type": "string", + "proposals": [ + "ros-base", + "desktop" + ], + "default": "desktop", + "description": "ROS 2 metapackage to install." + }, + "workspace": { + "type": "string", + "default": "${containerWorkspaceFolder}", + "description": "Workspace whose install/setup.sh file should be sourced when present." + } + }, + "installsAfter": [ + "ghcr.io/devcontainers/features/common-utils" + ] +} diff --git a/features/src/ros2/distributions.json b/features/src/ros2/distributions.json new file mode 100644 index 0000000..62bfaae --- /dev/null +++ b/features/src/ros2/distributions.json @@ -0,0 +1,26 @@ +{ + "ubuntuReleases": { + "jammy": { + "version": "22.04", + "defaultDistro": "humble", + "supportedDistros": [ + "humble" + ] + }, + "noble": { + "version": "24.04", + "defaultDistro": "jazzy", + "supportedDistros": [ + "jazzy", + "kilted" + ] + }, + "resolute": { + "version": "26.04", + "defaultDistro": "lyrical", + "supportedDistros": [ + "lyrical" + ] + } + } +} diff --git a/features/src/ros2/install.sh b/features/src/ros2/install.sh new file mode 100755 index 0000000..2369370 --- /dev/null +++ b/features/src/ros2/install.sh @@ -0,0 +1,128 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ros_distro="${DISTRO:-auto}" +ros_package="${PACKAGE:-desktop}" +ros_workspace="${WORKSPACE:-${_REMOTE_USER_HOME:-/workspaces}/ros2_ws}" +feature_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +distribution_config="${feature_dir}/distributions.json" + +if [[ "$(id -u)" -ne 0 ]]; then + echo "The ROS 2 Feature must run as root." >&2 + exit 1 +fi + +if [[ ! -r /etc/os-release ]]; then + echo "Unable to determine the base operating system." >&2 + exit 1 +fi + +# shellcheck disable=SC1091 +source /etc/os-release + +if [[ "${ID:-}" != "ubuntu" ]]; then + echo "ROS 2 deb packages are supported only on Ubuntu; found ${ID:-unknown}." >&2 + exit 1 +fi + +case "${ros_package}" in + ros-base | desktop) + ;; + *) + echo "Unsupported ROS 2 package: ${ros_package}." >&2 + exit 1 + ;; +esac + +export DEBIAN_FRONTEND=noninteractive + +apt-get update +apt-get install -y --no-install-recommends \ + ca-certificates \ + curl \ + jq \ + locales \ + software-properties-common + +ubuntu_codename="${UBUNTU_CODENAME:-${VERSION_CODENAME:-}}" +ubuntu_release="$(jq -c --arg codename "${ubuntu_codename}" '.ubuntuReleases[$codename] // empty' "${distribution_config}")" + +if [[ -z "${ubuntu_release}" ]]; then + echo "Ubuntu ${VERSION_ID:-unknown} (${ubuntu_codename:-unknown}) is not supported by this ROS 2 Feature." >&2 + exit 1 +fi + +ubuntu_version="$(jq -r '.version' <<<"${ubuntu_release}")" + +if [[ "${ros_distro}" == "auto" ]]; then + ros_distro="$(jq -r '.defaultDistro' <<<"${ubuntu_release}")" + echo "Selected ROS 2 ${ros_distro} for Ubuntu ${VERSION_ID:-unknown} (${ubuntu_codename})." +fi + +if ! jq -e --arg distro "${ros_distro}" '.supportedDistros | index($distro) != null' <<<"${ubuntu_release}" >/dev/null; then + supported_distros="$(jq -r '.supportedDistros | join(", ")' <<<"${ubuntu_release}")" + echo "ROS 2 ${ros_distro} is not supported on Ubuntu ${ubuntu_version} (${ubuntu_codename}); select one of: ${supported_distros}." >&2 + exit 1 +fi + +locale-gen en_US en_US.UTF-8 +update-locale LC_ALL=en_US.UTF-8 LANG=en_US.UTF-8 +add-apt-repository -y universe + +ros_apt_source_release_url="$( + curl --fail --silent --show-error --location \ + --output /dev/null \ + --write-out '%{url_effective}' \ + https://github.com/ros-infrastructure/ros-apt-source/releases/latest +)" +ros_apt_source_version="${ros_apt_source_release_url##*/}" + +if [[ ! "${ros_apt_source_version}" =~ ^[0-9]+(\.[0-9]+)+$ ]]; then + echo "Unable to determine the latest ros2-apt-source release." >&2 + exit 1 +fi + +ros_apt_source_package="/tmp/ros2-apt-source.deb" +curl --fail --silent --show-error --location \ + "https://github.com/ros-infrastructure/ros-apt-source/releases/download/${ros_apt_source_version}/ros2-apt-source_${ros_apt_source_version}.${ubuntu_codename}_all.deb" \ + --output "${ros_apt_source_package}" +dpkg -i "${ros_apt_source_package}" +rm -f "${ros_apt_source_package}" + +apt-get update +apt-get install -y --no-install-recommends \ + "ros-${ros_distro}-${ros_package}" \ + python3-argcomplete \ + ros-dev-tools + +if [[ ! -e /etc/ros/rosdep/sources.list.d/20-default.list ]]; then + rosdep init +fi + +cat > /etc/profile.d/ros2.sh <> "${shell_init}" + fi +done + +rm -rf /var/lib/apt/lists/* + +echo "Installed ROS 2 ${ros_distro} (${ros_package}) with ROS development tools." diff --git a/features/test/ros2/humble.sh b/features/test/ros2/humble.sh new file mode 100755 index 0000000..d1f3ee6 --- /dev/null +++ b/features/test/ros2/humble.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +source dev-container-features-test-lib + +check "ROS 2 Humble is selected" bash -lc '[ "${ROS_DISTRO:-}" = "humble" ]' +check "ROS 2 CLI is installed" bash -lc 'ros2 pkg prefix rclcpp >/dev/null' +check "development tools are installed" bash -lc 'command -v colcon >/dev/null && command -v rosdep >/dev/null && command -v vcs >/dev/null' + +reportResults diff --git a/features/test/ros2/kilted.sh b/features/test/ros2/kilted.sh new file mode 100755 index 0000000..9d7ef04 --- /dev/null +++ b/features/test/ros2/kilted.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +source dev-container-features-test-lib + +check "ROS 2 Kilted is selected" bash -lc '[ "${ROS_DISTRO:-}" = "kilted" ]' +check "ROS 2 CLI is installed" bash -lc 'ros2 pkg prefix rclcpp >/dev/null' +check "development tools are installed" bash -lc 'command -v colcon >/dev/null && command -v rosdep >/dev/null && command -v vcs >/dev/null' + +reportResults diff --git a/features/test/ros2/lyrical.sh b/features/test/ros2/lyrical.sh new file mode 100755 index 0000000..37a1171 --- /dev/null +++ b/features/test/ros2/lyrical.sh @@ -0,0 +1,11 @@ +#!/usr/bin/env bash + +set -e + +source dev-container-features-test-lib + +check "ROS 2 Lyrical is selected" bash -lc '[ "${ROS_DISTRO:-}" = "lyrical" ]' +check "ROS 2 CLI is installed" bash -lc 'ros2 pkg prefix rclcpp >/dev/null' +check "development tools are installed" bash -lc 'command -v colcon >/dev/null && command -v rosdep >/dev/null && command -v vcs >/dev/null' + +reportResults diff --git a/features/test/ros2/scenarios.json b/features/test/ros2/scenarios.json new file mode 100644 index 0000000..ee6984e --- /dev/null +++ b/features/test/ros2/scenarios.json @@ -0,0 +1,22 @@ +{ + "humble": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-22.04", + "features": { + "ros2": {} + } + }, + "lyrical": { + "image": "ubuntu:26.04", + "features": { + "ros2": {} + } + }, + "kilted": { + "image": "mcr.microsoft.com/devcontainers/base:ubuntu-24.04", + "features": { + "ros2": { + "distro": "kilted" + } + } + } +} diff --git a/features/test/ros2/test.sh b/features/test/ros2/test.sh new file mode 100755 index 0000000..24cae20 --- /dev/null +++ b/features/test/ros2/test.sh @@ -0,0 +1,16 @@ +#!/usr/bin/env bash + +set -e + +source dev-container-features-test-lib + +check "ROS 2 automatically selects Jazzy on Noble" bash -lc '[ "${ROS_DISTRO:-}" = "jazzy" ]' +check "ROS 2 CLI is installed" bash -lc 'ros2 pkg prefix rclcpp >/dev/null' +check "ROS 2 desktop is installed by default" bash -lc 'ros2 pkg prefix rviz2 >/dev/null' +check "colcon is installed" bash -lc 'command -v colcon >/dev/null' +check "rosdep is installed" bash -lc 'command -v rosdep >/dev/null' +check "vcs is installed" bash -lc 'command -v vcs >/dev/null' +check "rosdep is initialized" test -f /etc/ros/rosdep/sources.list.d/20-default.list +check "ROS profile is installed" test -f /etc/profile.d/ros2.sh + +reportResults diff --git a/features/test/test_all.sh b/features/test/test_all.sh index 45f3eeb..f7331e1 100755 --- a/features/test/test_all.sh +++ b/features/test/test_all.sh @@ -2,13 +2,15 @@ set -euo pipefail features_root="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" -base_image="${DEVCONTAINER_BASE_IMAGE:-mcr.microsoft.com/devcontainers/base:ubuntu}" +base_image="${DEVCONTAINER_BASE_IMAGE:-mcr.microsoft.com/devcontainers/base:ubuntu-24.04}" if ! command -v devcontainer >/dev/null 2>&1; then echo "devcontainer CLI is required. Install it with 'npm install -g @devcontainers/cli'." >&2 exit 1 fi +python3 "${features_root}/test/validate_ros2_config.py" + cleanup_paths=() cleanup() { @@ -40,7 +42,7 @@ touch "${XAUTHORITY}" touch "${xwayland_xauthority}" touch "${XDG_RUNTIME_DIR}/pulse-native" -for feature in linux-x11-forwarding; do +for feature in linux-x11-forwarding ros2; do devcontainer features test \ --base-image "${base_image}" \ --features "${feature}" \ diff --git a/features/test/validate_ros2_config.py b/features/test/validate_ros2_config.py new file mode 100755 index 0000000..cd1fcaf --- /dev/null +++ b/features/test/validate_ros2_config.py @@ -0,0 +1,51 @@ +#!/usr/bin/env python3 + +import json +from pathlib import Path +import re + + +feature_root = Path(__file__).resolve().parents[1] / "src" / "ros2" + +with (feature_root / "distributions.json").open(encoding="utf-8") as stream: + config = json.load(stream) + +with (feature_root / "devcontainer-feature.json").open(encoding="utf-8") as stream: + manifest = json.load(stream) + +ubuntu_releases = config["ubuntuReleases"] +proposals = manifest["options"]["distro"]["proposals"] +default = manifest["options"]["distro"]["default"] + +if set(config) != {"ubuntuReleases"}: + raise SystemExit("distributions.json must contain only ubuntuReleases") + +distributions = [] +for codename, settings in ubuntu_releases.items(): + expected_fields = {"version", "defaultDistro", "supportedDistros"} + if set(settings) != expected_fields: + raise SystemExit( + f"{codename} must define exactly: {', '.join(sorted(expected_fields))}" + ) + if not re.fullmatch(r"[a-z]+", codename): + raise SystemExit(f"{codename} is not a valid Ubuntu codename") + if not re.fullmatch(r"[0-9]{2}\.[0-9]{2}", settings["version"]): + raise SystemExit(f"{codename} has an invalid Ubuntu version") + if not settings["supportedDistros"]: + raise SystemExit(f"{codename} must support at least one ROS 2 distribution") + if settings["defaultDistro"] not in settings["supportedDistros"]: + raise SystemExit(f"{codename}'s default must be one of its supported distros") + for name in settings["supportedDistros"]: + if name in distributions: + raise SystemExit(f"ROS 2 {name} is listed for more than one Ubuntu release") + distributions.append(name) + +if proposals != ["auto", *distributions]: + raise SystemExit( + "ROS 2 distro proposals must be auto followed by distributions.json entries" + ) + +if default != "auto": + raise SystemExit("The ROS 2 distro option must default to auto") + +print(f"Validated {len(distributions)} ROS 2 distribution definitions")