ros2docker 0.1.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- ros2docker/__init__.py +19 -0
- ros2docker/__main__.py +8 -0
- ros2docker/api.py +152 -0
- ros2docker/cli.py +115 -0
- ros2docker/commands.py +191 -0
- ros2docker/config.py +316 -0
- ros2docker/resources/build/Dockerfile +192 -0
- ros2docker/resources/build/entrypoint.sh +72 -0
- ros2docker/resources/examples/ros2docker.json +51 -0
- ros2docker/resources/schema/ros2docker.schema.json +118 -0
- ros2docker-0.1.1.dist-info/METADATA +152 -0
- ros2docker-0.1.1.dist-info/RECORD +16 -0
- ros2docker-0.1.1.dist-info/WHEEL +5 -0
- ros2docker-0.1.1.dist-info/entry_points.txt +2 -0
- ros2docker-0.1.1.dist-info/licenses/LICENSE +28 -0
- ros2docker-0.1.1.dist-info/top_level.txt +1 -0
ros2docker/config.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
"""Configuration loading and path resolution for ros2docker."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import copy
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
from collections.abc import Mapping, Sequence
|
|
9
|
+
from functools import lru_cache
|
|
10
|
+
from importlib import resources
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from typing import Any
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ConfigError(ValueError):
|
|
16
|
+
"""Raised when a ros2docker config is invalid."""
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG: dict[str, Any] = {
|
|
20
|
+
"container_name": "ros2docker",
|
|
21
|
+
"image_name": "ros2docker",
|
|
22
|
+
"run_type": "bash",
|
|
23
|
+
"mount_ws": False,
|
|
24
|
+
"enable_gui_forwarding": False,
|
|
25
|
+
"forward_ssh_agent": False,
|
|
26
|
+
"run_args": [],
|
|
27
|
+
"extra_run_args": [],
|
|
28
|
+
"build_args": {},
|
|
29
|
+
"bake_ros_packages": [],
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
VALID_RUN_TYPES = {"bash", "catmux", "up", "command"}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@lru_cache
|
|
36
|
+
def public_config_keys() -> frozenset[str]:
|
|
37
|
+
"""Return the supported top-level ros2docker config keys."""
|
|
38
|
+
|
|
39
|
+
schema_text = (
|
|
40
|
+
resources.files("ros2docker")
|
|
41
|
+
.joinpath("resources")
|
|
42
|
+
.joinpath("schema")
|
|
43
|
+
.joinpath("ros2docker.schema.json")
|
|
44
|
+
.read_text(encoding="utf-8")
|
|
45
|
+
)
|
|
46
|
+
schema = json.loads(schema_text)
|
|
47
|
+
properties = schema.get("properties", {})
|
|
48
|
+
if not isinstance(properties, dict):
|
|
49
|
+
raise ConfigError("ros2docker config schema must define object properties.")
|
|
50
|
+
return frozenset(str(key) for key in properties)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def strip_json_comments(text: str) -> str:
|
|
54
|
+
"""Remove C/C++ style comments while preserving string literals."""
|
|
55
|
+
|
|
56
|
+
output: list[str] = []
|
|
57
|
+
in_string = False
|
|
58
|
+
escape = False
|
|
59
|
+
i = 0
|
|
60
|
+
|
|
61
|
+
while i < len(text):
|
|
62
|
+
char = text[i]
|
|
63
|
+
next_char = text[i + 1] if i + 1 < len(text) else ""
|
|
64
|
+
|
|
65
|
+
if in_string:
|
|
66
|
+
output.append(char)
|
|
67
|
+
if escape:
|
|
68
|
+
escape = False
|
|
69
|
+
elif char == "\\":
|
|
70
|
+
escape = True
|
|
71
|
+
elif char == '"':
|
|
72
|
+
in_string = False
|
|
73
|
+
i += 1
|
|
74
|
+
continue
|
|
75
|
+
|
|
76
|
+
if char == '"':
|
|
77
|
+
in_string = True
|
|
78
|
+
output.append(char)
|
|
79
|
+
i += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if char == "/" and next_char == "/":
|
|
83
|
+
i += 2
|
|
84
|
+
while i < len(text) and text[i] not in "\r\n":
|
|
85
|
+
i += 1
|
|
86
|
+
continue
|
|
87
|
+
|
|
88
|
+
if char == "/" and next_char == "*":
|
|
89
|
+
i += 2
|
|
90
|
+
while i + 1 < len(text) and not (text[i] == "*" and text[i + 1] == "/"):
|
|
91
|
+
i += 1
|
|
92
|
+
i += 2
|
|
93
|
+
continue
|
|
94
|
+
|
|
95
|
+
output.append(char)
|
|
96
|
+
i += 1
|
|
97
|
+
|
|
98
|
+
return "".join(output)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def parse_override(override: str | Mapping[str, Any] | None) -> dict[str, Any]:
|
|
102
|
+
if override is None:
|
|
103
|
+
return {}
|
|
104
|
+
if isinstance(override, str):
|
|
105
|
+
try:
|
|
106
|
+
parsed = json.loads(strip_json_comments(override))
|
|
107
|
+
except json.JSONDecodeError as exc:
|
|
108
|
+
raise ConfigError(f"Invalid JSON override: {exc}") from exc
|
|
109
|
+
elif isinstance(override, Mapping):
|
|
110
|
+
parsed = dict(override)
|
|
111
|
+
else:
|
|
112
|
+
raise ConfigError(f"Override must be JSON text or a mapping, got {type(override).__name__}.")
|
|
113
|
+
|
|
114
|
+
if not isinstance(parsed, dict):
|
|
115
|
+
raise ConfigError("Override must decode to a JSON object.")
|
|
116
|
+
return parsed
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_config_dir(config_file: str | os.PathLike[str] | None = None) -> str:
|
|
120
|
+
if config_file is None:
|
|
121
|
+
return os.getcwd()
|
|
122
|
+
return str(_resolve_config_file(config_file).parent)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def load_config(
|
|
126
|
+
config_file: str | os.PathLike[str] | None = None,
|
|
127
|
+
override: str | Mapping[str, Any] | None = None,
|
|
128
|
+
*,
|
|
129
|
+
resolve_run_args: bool = True,
|
|
130
|
+
) -> dict[str, Any]:
|
|
131
|
+
config = copy.deepcopy(DEFAULT_CONFIG)
|
|
132
|
+
config_dir = Path.cwd()
|
|
133
|
+
|
|
134
|
+
if config_file is not None:
|
|
135
|
+
config_path = _resolve_config_file(config_file)
|
|
136
|
+
if not config_path.is_file():
|
|
137
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
138
|
+
config_dir = config_path.parent
|
|
139
|
+
try:
|
|
140
|
+
loaded = json.loads(strip_json_comments(config_path.read_text(encoding="utf-8")))
|
|
141
|
+
except json.JSONDecodeError as exc:
|
|
142
|
+
raise ConfigError(f"Invalid JSON in {config_path}: {exc}") from exc
|
|
143
|
+
if not isinstance(loaded, dict):
|
|
144
|
+
raise ConfigError(f"Config file must contain a JSON object: {config_path}")
|
|
145
|
+
_validate_public_config_keys(loaded)
|
|
146
|
+
config.update(loaded)
|
|
147
|
+
|
|
148
|
+
override_config = parse_override(override)
|
|
149
|
+
_validate_public_config_keys(override_config)
|
|
150
|
+
config.update(override_config)
|
|
151
|
+
_normalize_config_paths(config, config_dir, resolve_run_args=resolve_run_args)
|
|
152
|
+
_validate_config(config)
|
|
153
|
+
return config
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _resolve_config_file(config_file: str | os.PathLike[str]) -> Path:
|
|
157
|
+
path = Path(os.path.expandvars(os.path.expanduser(os.fspath(config_file))))
|
|
158
|
+
if not path.is_absolute():
|
|
159
|
+
path = Path.cwd() / path
|
|
160
|
+
return path.resolve()
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _validate_public_config_keys(config: Mapping[str, Any]) -> None:
|
|
164
|
+
unknown_keys = sorted(set(config) - public_config_keys())
|
|
165
|
+
if unknown_keys:
|
|
166
|
+
key = unknown_keys[0]
|
|
167
|
+
raise ConfigError(f"Unknown config key {key!r}. This key is not part of ros2docker core.")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def _validate_config(config: Mapping[str, Any]) -> None:
|
|
171
|
+
run_type = config.get("run_type", "bash")
|
|
172
|
+
if run_type not in VALID_RUN_TYPES:
|
|
173
|
+
raise ConfigError(f"Unsupported run_type {run_type!r}. Expected one of: {', '.join(sorted(VALID_RUN_TYPES))}.")
|
|
174
|
+
|
|
175
|
+
for list_key in ("run_args", "extra_run_args", "bake_ros_packages"):
|
|
176
|
+
if not isinstance(config.get(list_key, []), list):
|
|
177
|
+
raise ConfigError(f"{list_key!r} must be a list.")
|
|
178
|
+
|
|
179
|
+
if not isinstance(config.get("build_args", {}), dict):
|
|
180
|
+
raise ConfigError("'build_args' must be a JSON object.")
|
|
181
|
+
|
|
182
|
+
if run_type == "catmux" and not config.get("catmux_file"):
|
|
183
|
+
raise ConfigError("'catmux_file' is required when run_type is 'catmux'.")
|
|
184
|
+
|
|
185
|
+
if run_type == "command":
|
|
186
|
+
command = config.get("command")
|
|
187
|
+
if not isinstance(command, str | list):
|
|
188
|
+
raise ConfigError("'command' must be a string or list when run_type is 'command'.")
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _normalize_config_paths(config: dict[str, Any], config_dir: Path, *, resolve_run_args: bool) -> None:
|
|
192
|
+
if resolve_run_args:
|
|
193
|
+
config["run_args"] = normalize_docker_host_paths(config.get("run_args", []), config_dir)
|
|
194
|
+
config["extra_run_args"] = normalize_docker_host_paths(config.get("extra_run_args", []), config_dir)
|
|
195
|
+
|
|
196
|
+
bake_packages = []
|
|
197
|
+
for raw_path in config.get("bake_ros_packages", []):
|
|
198
|
+
resolved = resolve_host_path(raw_path, config_dir)
|
|
199
|
+
if not resolved.exists():
|
|
200
|
+
raise FileNotFoundError(f"bake_ros_packages path does not exist: {raw_path!r} (resolved to {resolved})")
|
|
201
|
+
if not resolved.is_dir():
|
|
202
|
+
raise NotADirectoryError(
|
|
203
|
+
f"bake_ros_packages path must be a directory: {raw_path!r} (resolved to {resolved})"
|
|
204
|
+
)
|
|
205
|
+
bake_packages.append(str(resolved))
|
|
206
|
+
config["bake_ros_packages"] = bake_packages
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def normalize_docker_host_paths(
|
|
210
|
+
args: Sequence[str],
|
|
211
|
+
base_dir: str | os.PathLike[str],
|
|
212
|
+
) -> list[str]:
|
|
213
|
+
normalized: list[str] = []
|
|
214
|
+
i = 0
|
|
215
|
+
base_path = Path(base_dir)
|
|
216
|
+
args_list = list(args)
|
|
217
|
+
|
|
218
|
+
while i < len(args_list):
|
|
219
|
+
arg = args_list[i]
|
|
220
|
+
|
|
221
|
+
if arg in {"-v", "--volume"}:
|
|
222
|
+
if i + 1 >= len(args_list):
|
|
223
|
+
raise ConfigError(f"{arg} requires a following volume spec.")
|
|
224
|
+
normalized.extend([arg, normalize_volume_spec(args_list[i + 1], base_path)])
|
|
225
|
+
i += 2
|
|
226
|
+
continue
|
|
227
|
+
|
|
228
|
+
if arg.startswith("--volume="):
|
|
229
|
+
volume = arg.split("=", 1)[1]
|
|
230
|
+
normalized.append(f"--volume={normalize_volume_spec(volume, base_path)}")
|
|
231
|
+
i += 1
|
|
232
|
+
continue
|
|
233
|
+
|
|
234
|
+
if arg == "--mount":
|
|
235
|
+
if i + 1 >= len(args_list):
|
|
236
|
+
raise ConfigError("--mount requires a following mount spec.")
|
|
237
|
+
normalized.extend([arg, normalize_mount_spec(args_list[i + 1], base_path)])
|
|
238
|
+
i += 2
|
|
239
|
+
continue
|
|
240
|
+
|
|
241
|
+
if arg.startswith("--mount="):
|
|
242
|
+
mount = arg.split("=", 1)[1]
|
|
243
|
+
normalized.append(f"--mount={normalize_mount_spec(mount, base_path)}")
|
|
244
|
+
i += 1
|
|
245
|
+
continue
|
|
246
|
+
|
|
247
|
+
normalized.append(arg)
|
|
248
|
+
i += 1
|
|
249
|
+
|
|
250
|
+
return normalized
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def normalize_volume_spec(spec: str, base_dir: Path) -> str:
|
|
254
|
+
parts = spec.split(":")
|
|
255
|
+
if len(parts) < 2:
|
|
256
|
+
return spec
|
|
257
|
+
|
|
258
|
+
host_path = parts[0]
|
|
259
|
+
if not _looks_like_host_path(host_path):
|
|
260
|
+
return spec
|
|
261
|
+
|
|
262
|
+
resolved = resolve_host_path(host_path, base_dir)
|
|
263
|
+
parts[0] = str(resolved)
|
|
264
|
+
return ":".join(parts)
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
def normalize_mount_spec(spec: str, base_dir: Path) -> str:
|
|
268
|
+
fields = spec.split(",")
|
|
269
|
+
parsed: list[str] = []
|
|
270
|
+
is_bind = any(field == "type=bind" for field in fields)
|
|
271
|
+
|
|
272
|
+
for field in fields:
|
|
273
|
+
key, sep, value = field.partition("=")
|
|
274
|
+
if is_bind and sep and key in {"source", "src"}:
|
|
275
|
+
parsed.append(f"{key}={resolve_host_path(value, base_dir)}")
|
|
276
|
+
else:
|
|
277
|
+
parsed.append(field)
|
|
278
|
+
|
|
279
|
+
return ",".join(parsed)
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def resolve_host_path(
|
|
283
|
+
raw_path: str | os.PathLike[str],
|
|
284
|
+
base_dir: str | os.PathLike[str],
|
|
285
|
+
) -> Path:
|
|
286
|
+
text = os.fspath(raw_path)
|
|
287
|
+
expanded = os.path.expanduser(os.path.expandvars(text))
|
|
288
|
+
if "$" in expanded:
|
|
289
|
+
raise ConfigError(f"Could not expand environment variables in host path: {text!r}")
|
|
290
|
+
|
|
291
|
+
path = Path(expanded)
|
|
292
|
+
if _is_relative_path(text):
|
|
293
|
+
path = Path(base_dir) / expanded
|
|
294
|
+
elif not path.is_absolute() and _looks_like_host_path(text):
|
|
295
|
+
path = Path.cwd() / expanded
|
|
296
|
+
|
|
297
|
+
resolved = path.resolve()
|
|
298
|
+
if not resolved.exists():
|
|
299
|
+
raise FileNotFoundError(f"Host path does not exist: {text!r} (resolved to {resolved})")
|
|
300
|
+
return resolved
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _is_relative_path(path: str) -> bool:
|
|
304
|
+
return path in {".", ".."} or path.startswith("./") or path.startswith("../")
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def _looks_like_host_path(path: str) -> bool:
|
|
308
|
+
expanded = os.path.expanduser(os.path.expandvars(path))
|
|
309
|
+
return (
|
|
310
|
+
path in {".", ".."}
|
|
311
|
+
or path.startswith("./")
|
|
312
|
+
or path.startswith("../")
|
|
313
|
+
or path.startswith("~")
|
|
314
|
+
or path.startswith("$")
|
|
315
|
+
or os.path.isabs(expanded)
|
|
316
|
+
)
|
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
# Use the official image for ROS2 Lyrical under Ubuntu 26.04
|
|
2
|
+
ARG BASE_IMAGE=osrf/ros:lyrical-desktop-full-resolute
|
|
3
|
+
# Pin digests for reproducibility. Leave empty to use the latest image. The digest can be found on Docker Hub for the respective tag, e.g. for osrf/ros:lyrical-desktop-full-resolute
|
|
4
|
+
# Default: Latest Digist as of 2026-06-10 for https://hub.docker.com/layers/osrf/ros/lyrical-desktop-full-resolute
|
|
5
|
+
ARG DIGEST=@sha256:ac6471b5c13e57674d9660630d0cb140f518cb5ea6add7169e5dd91e16bf0b50
|
|
6
|
+
|
|
7
|
+
FROM ${BASE_IMAGE}${DIGEST}
|
|
8
|
+
|
|
9
|
+
##############################################################################
|
|
10
|
+
# 1) Base Environment
|
|
11
|
+
##############################################################################
|
|
12
|
+
ARG TZ=Europe/Berlin
|
|
13
|
+
ENV TZ=${TZ}
|
|
14
|
+
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
|
|
15
|
+
ENV LANG=C.UTF-8
|
|
16
|
+
ENV LC_ALL=C.UTF-8
|
|
17
|
+
ENV DEBIAN_FRONTEND=noninteractive
|
|
18
|
+
|
|
19
|
+
##############################################################################
|
|
20
|
+
# 2) Install Base Apt Dependencies
|
|
21
|
+
##############################################################################
|
|
22
|
+
RUN apt-get update && apt-get install -y \
|
|
23
|
+
tmux \
|
|
24
|
+
python3-pip \
|
|
25
|
+
python3-venv \
|
|
26
|
+
gosu \
|
|
27
|
+
ssh \
|
|
28
|
+
curl \
|
|
29
|
+
sudo \
|
|
30
|
+
vim \
|
|
31
|
+
git \
|
|
32
|
+
wget \
|
|
33
|
+
unzip \
|
|
34
|
+
moreutils \
|
|
35
|
+
ca-certificates \
|
|
36
|
+
build-essential \
|
|
37
|
+
libyaml-cpp-dev \
|
|
38
|
+
x11-apps \
|
|
39
|
+
iperf3 \
|
|
40
|
+
# ros-$ROS_DISTRO-domain-bridge \ not available in Lyrical yet. include soon!
|
|
41
|
+
ros-$ROS_DISTRO-foxglove-bridge \
|
|
42
|
+
ros-$ROS_DISTRO-foxglove-msgs \
|
|
43
|
+
ros-$ROS_DISTRO-topic-tools \
|
|
44
|
+
ros-$ROS_DISTRO-rmw-cyclonedds-cpp \
|
|
45
|
+
ros-$ROS_DISTRO-rmw-zenoh-cpp \
|
|
46
|
+
ros-$ROS_DISTRO-ffmpeg-image-transport \
|
|
47
|
+
ros-$ROS_DISTRO-foxglove-compressed-video-transport \
|
|
48
|
+
ros-$ROS_DISTRO-gps-msgs \
|
|
49
|
+
ros-$ROS_DISTRO-ament-copyright \
|
|
50
|
+
ros-$ROS_DISTRO-ament-flake8 \
|
|
51
|
+
ros-$ROS_DISTRO-ament-pep257 \
|
|
52
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
53
|
+
|
|
54
|
+
##############################################################################
|
|
55
|
+
# 2b) Custom overlay workspace – packages baked into the image
|
|
56
|
+
# (third-party source-only packages + project-specific bake_packages)
|
|
57
|
+
# Workspace chain: /opt/ros/$ROS_DISTRO → /opt/custom_ws/install
|
|
58
|
+
##############################################################################
|
|
59
|
+
ENV CUSTOM_WS=/opt/custom_ws
|
|
60
|
+
|
|
61
|
+
# novatel_oem7_msgs: no binary deb for Kilted yet, build from source
|
|
62
|
+
RUN mkdir -p ${CUSTOM_WS}/src \
|
|
63
|
+
&& git clone --depth 1 --branch kilted \
|
|
64
|
+
https://github.com/novatel/novatel_oem7_driver.git \
|
|
65
|
+
${CUSTOM_WS}/src/novatel_oem7_driver
|
|
66
|
+
|
|
67
|
+
# bake_packages: project-specific ROS packages staged by build.py
|
|
68
|
+
# from paths listed in config "bake_ros_packages"
|
|
69
|
+
COPY bake_packages/ ${CUSTOM_WS}/src/
|
|
70
|
+
|
|
71
|
+
RUN . /opt/ros/${ROS_DISTRO}/setup.sh \
|
|
72
|
+
&& cd ${CUSTOM_WS} \
|
|
73
|
+
&& colcon build --merge-install --packages-select novatel_oem7_msgs \
|
|
74
|
+
&& rm -rf ${CUSTOM_WS}/src/novatel_oem7_driver \
|
|
75
|
+
&& colcon build --merge-install \
|
|
76
|
+
&& rm -rf ${CUSTOM_WS}/src ${CUSTOM_WS}/build ${CUSTOM_WS}/log
|
|
77
|
+
|
|
78
|
+
# install zenoh and zenoh-plugin-ros2dds
|
|
79
|
+
ARG ZENOH_VERSION=1.9.0
|
|
80
|
+
ARG ZENOH_ROS2DDS_VERSION=${ZENOH_VERSION}
|
|
81
|
+
ENV ZENOH_PLATFORM=x86_64-unknown-linux-gnu
|
|
82
|
+
# Zenoh router
|
|
83
|
+
# - see: https://download.eclipse.org/zenoh/zenoh/latest/
|
|
84
|
+
RUN mkdir -p /opt/zenoh \
|
|
85
|
+
&& wget -qO /tmp/zenoh.zip \
|
|
86
|
+
"https://download.eclipse.org/zenoh/zenoh/${ZENOH_VERSION}/zenoh-${ZENOH_VERSION}-${ZENOH_PLATFORM}-standalone.zip" \
|
|
87
|
+
&& unzip -q /tmp/zenoh.zip -d /opt/zenoh \
|
|
88
|
+
&& rm /tmp/zenoh.zip
|
|
89
|
+
# ROS2 bridge plugin
|
|
90
|
+
# - see: https://download.eclipse.org/zenoh/zenoh-plugin-ros2dds/latest/
|
|
91
|
+
RUN mkdir -p /opt/zenoh/lib \
|
|
92
|
+
&& wget -qO /tmp/zenoh-plugin-ros2dds.zip \
|
|
93
|
+
"https://download.eclipse.org/zenoh/zenoh-plugin-ros2dds/${ZENOH_ROS2DDS_VERSION}/zenoh-plugin-ros2dds-${ZENOH_ROS2DDS_VERSION}-${ZENOH_PLATFORM}-standalone.zip" \
|
|
94
|
+
&& unzip -q /tmp/zenoh-plugin-ros2dds.zip -d /opt/zenoh \
|
|
95
|
+
&& rm /tmp/zenoh-plugin-ros2dds.zip
|
|
96
|
+
ENV PATH="/opt/zenoh:${PATH}"
|
|
97
|
+
|
|
98
|
+
# mcap CLI for fast filtering of rosbags
|
|
99
|
+
ARG MCAP_CLI_VERSION=0.0.62
|
|
100
|
+
RUN curl -L https://github.com/foxglove/mcap/releases/download/releases%2Fmcap-cli%2Fv${MCAP_CLI_VERSION}/mcap-linux-amd64 -o /usr/local/bin/mcap \
|
|
101
|
+
&& chmod +x /usr/local/bin/mcap
|
|
102
|
+
|
|
103
|
+
# ##############################################################################
|
|
104
|
+
# # 3) Setup shared directory
|
|
105
|
+
# ##############################################################################
|
|
106
|
+
# RUN groupadd --system shared && usermod -aG shared root
|
|
107
|
+
# RUN mkdir -p /ros2ws && \
|
|
108
|
+
# chown root:shared /ros2ws && \
|
|
109
|
+
# chmod g+rwXs /ros2ws
|
|
110
|
+
# RUN echo 'umask 002' >> /etc/profile.d/umask.sh
|
|
111
|
+
# RUN setfacl -d -m g::rwx /ros2ws
|
|
112
|
+
|
|
113
|
+
##############################################################################
|
|
114
|
+
# 5) Setup Virtual Python Environment
|
|
115
|
+
##############################################################################
|
|
116
|
+
ENV PYTHON_VENV_PATH=/opt/ros_venv
|
|
117
|
+
RUN python3 -m venv $PYTHON_VENV_PATH --system-site-packages
|
|
118
|
+
ENV PATH="$PYTHON_VENV_PATH/bin:$PATH"
|
|
119
|
+
# set PYTHONPATH to include the venv site-packages. Hacky but might be required.
|
|
120
|
+
# RUN VENV_SITE="$("$PYTHON_VENV_PATH/bin/python" -c 'import sysconfig; print(sysconfig.get_path("purelib"))')" \
|
|
121
|
+
# && ln -sfn "$VENV_SITE" "$PYTHON_VENV_PATH/site-packages"
|
|
122
|
+
# ENV PYTHONPATH="${PYTHON_VENV_PATH}/site-packages"
|
|
123
|
+
|
|
124
|
+
##############################################################################
|
|
125
|
+
# 6) Install Base Pip Dependencies
|
|
126
|
+
##############################################################################
|
|
127
|
+
RUN pip install --no-cache-dir --upgrade pip && \
|
|
128
|
+
pip install --no-cache-dir \
|
|
129
|
+
PyYAML \
|
|
130
|
+
catmux \
|
|
131
|
+
rosbags
|
|
132
|
+
|
|
133
|
+
##############################################################################
|
|
134
|
+
# 7) Hotfix: Replace deprecated rosdep version shipped with ROS 2 Jazzy image
|
|
135
|
+
##############################################################################
|
|
136
|
+
# The base image includes an outdated rosdep installed via APT,
|
|
137
|
+
# which uses the deprecated 'pkg_resources' API and causes a DeprecationWarning.
|
|
138
|
+
# To avoid this warning, we remove the APT version and install the latest rosdep via pip.
|
|
139
|
+
RUN apt-get remove -y python3-rosdep && \
|
|
140
|
+
pip install --no-cache-dir --upgrade rosdep
|
|
141
|
+
|
|
142
|
+
##############################################################################
|
|
143
|
+
# 8) Install user-specified APT dependencies
|
|
144
|
+
##############################################################################
|
|
145
|
+
ARG APT_PACKAGES=""
|
|
146
|
+
RUN if [ -n "${APT_PACKAGES}" ]; then \
|
|
147
|
+
apt-get update && \
|
|
148
|
+
apt-get install -y ${APT_PACKAGES} && \
|
|
149
|
+
rm -rf /var/lib/apt/lists/*; \
|
|
150
|
+
fi
|
|
151
|
+
|
|
152
|
+
##############################################################################
|
|
153
|
+
# 9) Install user-specified Python packages
|
|
154
|
+
##############################################################################
|
|
155
|
+
ARG PIP_PACKAGES=""
|
|
156
|
+
RUN if [ -n "${PIP_PACKAGES}" ]; then \
|
|
157
|
+
pip install --no-cache-dir ${PIP_PACKAGES}; \
|
|
158
|
+
fi
|
|
159
|
+
|
|
160
|
+
##############################################################################
|
|
161
|
+
# 10) Environment settings
|
|
162
|
+
##############################################################################
|
|
163
|
+
RUN echo "source ${PYTHON_VENV_PATH}/bin/activate" >> /root/.bashrc
|
|
164
|
+
RUN echo "source ${CUSTOM_WS}/install/setup.bash" >> /root/.bashrc
|
|
165
|
+
RUN echo '[ -f /ros2ws/install/setup.bash ] && source /ros2ws/install/setup.bash' >> /root/.bashrc
|
|
166
|
+
RUN echo 'alias catmux="tmux -L catmux"' >> /root/.bashrc
|
|
167
|
+
ENV ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST
|
|
168
|
+
ENV RMW_IMPLEMENTATION=rmw_cyclonedds_cpp
|
|
169
|
+
ENV SHELL=/bin/bash
|
|
170
|
+
SHELL ["/bin/bash", "-c"]
|
|
171
|
+
|
|
172
|
+
##############################################################################
|
|
173
|
+
# 11) EntryPoint
|
|
174
|
+
##############################################################################
|
|
175
|
+
COPY entrypoint.sh /entrypoint.sh
|
|
176
|
+
RUN chmod +x /entrypoint.sh
|
|
177
|
+
ENTRYPOINT ["/entrypoint.sh"]
|
|
178
|
+
|
|
179
|
+
##############################################################################
|
|
180
|
+
# 12) Create Non-Root User
|
|
181
|
+
##############################################################################
|
|
182
|
+
ARG USER_UID=1000
|
|
183
|
+
ARG USER_GID=$USER_UID
|
|
184
|
+
ENV CONTAINER_USERNAME=containeruser
|
|
185
|
+
RUN userdel -r $(id -un 1000) \
|
|
186
|
+
&& groupadd --gid "$USER_GID" "$CONTAINER_USERNAME" \
|
|
187
|
+
&& useradd --uid "$USER_UID" --gid "$USER_GID" -m "$CONTAINER_USERNAME" \
|
|
188
|
+
&& echo "$CONTAINER_USERNAME ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/$CONTAINER_USERNAME \
|
|
189
|
+
&& chmod 0440 /etc/sudoers.d/$CONTAINER_USERNAME \
|
|
190
|
+
&& cp /root/.bashrc "/home/$CONTAINER_USERNAME/.bashrc" \
|
|
191
|
+
&& chown "$USER_UID:$USER_GID" "/home/$CONTAINER_USERNAME/.bashrc"
|
|
192
|
+
# && usermod -aG shared "$CONTAINER_USERNAME" \
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
|
|
4
|
+
if [[ "${ROS2DOCKER_TRACE:-0}" == "1" ]]; then
|
|
5
|
+
set -x
|
|
6
|
+
fi
|
|
7
|
+
|
|
8
|
+
echo "**** Entrypoint script starting ****"
|
|
9
|
+
|
|
10
|
+
source "${CUSTOM_WS}/install/setup.bash"
|
|
11
|
+
|
|
12
|
+
source_ros2_workspace() {
|
|
13
|
+
local setup_file="$1"
|
|
14
|
+
if [[ ! -f "$setup_file" ]]; then
|
|
15
|
+
return 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
source "$setup_file"
|
|
19
|
+
echo "Sourced ROS 2 workspace overlay: $setup_file"
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if [ -d "/ws/ros2src" ]; then
|
|
23
|
+
echo "ROS 2 workspace found at /ws/ros2src"
|
|
24
|
+
|
|
25
|
+
sudo mkdir -p /ros2ws
|
|
26
|
+
sudo chown "$CONTAINER_USERNAME":"$CONTAINER_USERNAME" /ros2ws
|
|
27
|
+
|
|
28
|
+
if [[ -L /ros2ws/src ]]; then
|
|
29
|
+
ln -sfn /ws/ros2src /ros2ws/src
|
|
30
|
+
elif [[ -e /ros2ws/src ]]; then
|
|
31
|
+
echo "/ros2ws/src already exists and is not a symlink."
|
|
32
|
+
exit 1
|
|
33
|
+
else
|
|
34
|
+
ln -s /ws/ros2src /ros2ws/src
|
|
35
|
+
fi
|
|
36
|
+
|
|
37
|
+
if [[ "${CHECK_ROS2WS_DEPENDENCIES:-0}" == "1" ]]; then
|
|
38
|
+
echo "Checking for missing dependencies..."
|
|
39
|
+
# sudo /opt/ros_venv/bin/pip install --upgrade rosdep # not sure if needed
|
|
40
|
+
rosdep update --rosdistro "${ROS_DISTRO}"
|
|
41
|
+
if rosdep install --from-paths /ros2ws/src --ignore-src --simulate; then
|
|
42
|
+
echo "All dependencies are satisfied."
|
|
43
|
+
else
|
|
44
|
+
echo "Dependencies are missing. See rosdep output above."
|
|
45
|
+
exit 1
|
|
46
|
+
fi
|
|
47
|
+
else
|
|
48
|
+
echo "Skipping dependency check."
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
if [[ "${BUILD_ROS2WS:-0}" == "1" ]]; then
|
|
52
|
+
echo "Building ROS 2 workspace..."
|
|
53
|
+
pushd /ros2ws >/dev/null
|
|
54
|
+
colcon_args=()
|
|
55
|
+
if [[ "${ROS2WS_SYMLINK_INSTALL:-1}" == "1" ]]; then
|
|
56
|
+
colcon_args+=(--symlink-install)
|
|
57
|
+
fi
|
|
58
|
+
if [[ "${ROS2WS_SUPPRESS_UNUSED_CMAKE_WARNINGS:-1}" == "1" ]]; then
|
|
59
|
+
colcon_args+=(--cmake-args --no-warn-unused-cli)
|
|
60
|
+
fi
|
|
61
|
+
colcon build "${colcon_args[@]}"
|
|
62
|
+
popd >/dev/null
|
|
63
|
+
else
|
|
64
|
+
echo "Skipping building ROS 2 workspace."
|
|
65
|
+
fi
|
|
66
|
+
|
|
67
|
+
source_ros2_workspace /ros2ws/install/setup.bash
|
|
68
|
+
else
|
|
69
|
+
echo "No ROS 2 workspace found at /ws/ros2src. Skipping"
|
|
70
|
+
fi
|
|
71
|
+
|
|
72
|
+
exec "$@"
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
// Run settings
|
|
3
|
+
"container_name": "example_ros2container",
|
|
4
|
+
"image_name": "ros2docker",
|
|
5
|
+
|
|
6
|
+
// Supported run types:
|
|
7
|
+
// "bash" - interactive shell
|
|
8
|
+
// "command" - run the configured command
|
|
9
|
+
// "catmux" - start a catmux session
|
|
10
|
+
// "up" - detached keepalive container
|
|
11
|
+
"run_type": "catmux",
|
|
12
|
+
|
|
13
|
+
// Used when run_type = "catmux".
|
|
14
|
+
"catmux_file": "/ws/catmux.yaml",
|
|
15
|
+
"catmux_params": {
|
|
16
|
+
"mock_param": "mock_value"
|
|
17
|
+
},
|
|
18
|
+
|
|
19
|
+
// Used when run_type = "command". String and argv-list forms are supported.
|
|
20
|
+
"command": ["ros2", "topic", "list"],
|
|
21
|
+
|
|
22
|
+
// Mount the config-adjacent ws directory into /ws.
|
|
23
|
+
"mount_ws": true,
|
|
24
|
+
|
|
25
|
+
// Enable X11 forwarding through /tmp/.X11-unix.
|
|
26
|
+
"enable_gui_forwarding": false,
|
|
27
|
+
"forward_ssh_agent": false,
|
|
28
|
+
|
|
29
|
+
// Extra docker run arguments.
|
|
30
|
+
"run_args": [
|
|
31
|
+
// "-e", "ROS_AUTOMATIC_DISCOVERY_RANGE=LOCALHOST", // (default)
|
|
32
|
+
// "-e", "RMW_IMPLEMENTATION=rmw_cyclonedds_cpp", // (default)
|
|
33
|
+
// "-e", "ROS_DOMAIN_ID=0", // (default)
|
|
34
|
+
// If /ws/ros2src exists, BUILD_ROS2WS uses symlink-install and sources
|
|
35
|
+
// /ros2ws/install/setup.bash before the configured command starts.
|
|
36
|
+
"-e", "BUILD_ROS2WS=1",
|
|
37
|
+
"-e", "CHECK_ROS2WS_DEPENDENCIES=1"
|
|
38
|
+
],
|
|
39
|
+
"extra_run_args": [],
|
|
40
|
+
|
|
41
|
+
// ROS packages to pre-build into the Docker image, relative to this config.
|
|
42
|
+
// These are installed into /opt/ros/$ROS_DISTRO so every container gets them without
|
|
43
|
+
// needing them in each workspace's ros2src/.
|
|
44
|
+
"bake_ros_packages": [],
|
|
45
|
+
|
|
46
|
+
// Optional Docker build arguments.
|
|
47
|
+
"build_args": {
|
|
48
|
+
"APT_PACKAGES": "git wget curl",
|
|
49
|
+
"PIP_PACKAGES": "pytest pytest-cov"
|
|
50
|
+
}
|
|
51
|
+
}
|