-
Notifications
You must be signed in to change notification settings - Fork 3.9k
Expand file tree
/
Copy pathtest_dockerfile_nonroot.py
More file actions
246 lines (187 loc) · 10.6 KB
/
Copy pathtest_dockerfile_nonroot.py
File metadata and controls
246 lines (187 loc) · 10.6 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
# Copyright (c) 2022-2026, The Isaac Lab Project Developers (https://github.com/isaac-sim/IsaacLab/blob/main/CONTRIBUTORS.md).
# All rights reserved.
#
# SPDX-License-Identifier: BSD-3-Clause
import importlib.util
import re
from pathlib import Path
import pytest
import tomllib
REPO_ROOT = Path(__file__).resolve().parents[2]
DOCKER_DIR = REPO_ROOT / "docker"
def _load_module(name: str, path: Path):
"""Import a module by file path (``docker`` is not an importable package here)."""
spec = importlib.util.spec_from_file_location(name, path)
assert spec is not None and spec.loader is not None, f"cannot load module at {path}"
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
# Collect every Dockerfile.* from the entire repository tree.
DOCKERFILES = sorted(REPO_ROOT.glob("**/Dockerfile.*"))
ROOT_USERS = {"root", "0"}
# Keep every Dockerfile in this map so new containers must make an explicit
# runtime-user decision instead of silently escaping this regression test.
# Keys are Dockerfile *names* (unique across the repo); values are the
# expected final USER directive (None = not yet migrated, test skipped).
DOCKERFILE_RUNTIME_USERS = {
"Dockerfile.base": "isaaclab",
"Dockerfile.curobo": "isaaclab",
"Dockerfile.installci": "isaaclab",
"Dockerfile.kitless": "isaaclab",
"Dockerfile.ros2": "isaaclab",
}
# Dockerfiles that are expected to *create* the non-root runtime user
# (i.e. contain groupadd/useradd/USER isaaclab).
DOCKERFILES_CREATING_RUNTIME_USER = {
"Dockerfile.base",
"Dockerfile.curobo",
"Dockerfile.installci",
"Dockerfile.kitless",
}
USER_DIRECTIVE_RE = re.compile(r"^USER\s+(\S+)\s*$")
def _user_directives(dockerfile_text: str) -> list[str]:
users = []
for raw_line in dockerfile_text.splitlines():
line = raw_line.strip()
if line.startswith("#"):
continue
match = USER_DIRECTIVE_RE.match(line)
if match:
users.append(match.group(1))
return users
def _final_user(dockerfile_path: Path) -> str | None:
users = _user_directives(dockerfile_path.read_text(encoding="utf-8"))
return users[-1] if users else None
def _find_dockerfile(name: str) -> Path:
"""Return the path of the unique Dockerfile with the given name."""
matches = [p for p in DOCKERFILES if p.name == name]
assert len(matches) == 1, f"Expected exactly one {name}, found: {matches}"
return matches[0]
def test_all_dockerfiles_have_runtime_user_expectations():
expected_dockerfiles = set(DOCKERFILE_RUNTIME_USERS)
actual_dockerfiles = {dockerfile.name for dockerfile in DOCKERFILES}
assert actual_dockerfiles == expected_dockerfiles
@pytest.mark.parametrize("dockerfile", DOCKERFILES, ids=lambda path: path.name)
def test_non_root_runtime_dockerfiles(dockerfile: Path):
expected_user = DOCKERFILE_RUNTIME_USERS[dockerfile.name]
if expected_user is None:
pytest.skip(f"{dockerfile.name} has not been migrated to a non-root runtime user.")
final_user = _final_user(dockerfile)
assert final_user == expected_user
assert final_user not in ROOT_USERS
@pytest.mark.parametrize("dockerfile_name", sorted(DOCKERFILES_CREATING_RUNTIME_USER))
def test_dockerfile_creates_non_root_runtime_user(dockerfile_name: str):
dockerfile_text = _find_dockerfile(dockerfile_name).read_text(encoding="utf-8")
assert re.search(r"\bgroupadd\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL)
assert re.search(r"\buseradd\b.*--uid\s+1000\b.*--gid\s+1000\b.*\bisaaclab\b", dockerfile_text, re.DOTALL)
assert "USER isaaclab" in dockerfile_text
def test_ros2_dockerfile_restores_non_root_runtime_user():
dockerfile_text = (DOCKER_DIR / "Dockerfile.ros2").read_text(encoding="utf-8")
assert _user_directives(dockerfile_text) == ["root", "isaaclab"]
def test_kitless_dockerfile_installs_newton_rl_ov_and_visualizers_without_isaac_sim():
"""The kit-less image installs its runtime features and importers without the full Isaac Sim runtime."""
dockerfile_text = (DOCKER_DIR / "Dockerfile.kitless").read_text(encoding="utf-8")
with (REPO_ROOT / "pyproject.toml").open("rb") as file:
importer_requirements = tomllib.load(file)["project"]["optional-dependencies"]["importers"]
assert (
"FROM ghcr.io/astral-sh/uv:0.9.25@sha256:13e233d08517abdafac4ead26c16d881cd77504a2c40c38c905cf3a0d70131a6 AS uv"
in dockerfile_text
)
# Installed through the same entry point as Dockerfile.base/Dockerfile.curobo.
assert '"${ISAACLAB_PATH}/isaaclab.sh" --install newton,rl[all],ov[all],visualizer[all]' in dockerfile_text
assert "COPY tools/wheel_builder/uv-overrides.txt tools/wheel_builder/uv-overrides.txt" in dockerfile_text
assert '--overrides "${ISAACLAB_PATH}/tools/wheel_builder/uv-overrides.txt"' in dockerfile_text
assert all(f'"{requirement}"' in dockerfile_text for requirement in importer_requirements)
assert "COPY isaaclab.sh ./" in dockerfile_text
assert "'isaacsim' not in names" in dockerfile_text
assert "'isaacsim-asset-isolated' in names" in dockerfile_text
assert "'ovphysx' in names" in dockerfile_text
assert "'ovrtx' in names" in dockerfile_text
assert "'viser' in names" in dockerfile_text
assert "'rerun-sdk' in names" in dockerfile_text
assert "libxrender1" in dockerfile_text
assert 'test ! -e "${ISAACLAB_PATH}/_isaac_sim"' in dockerfile_text
assert "COPY docker/docker-compose.yaml docker/docker-compose.yaml" in dockerfile_text
assert "COPY docker/utils/volume_mounts.py docker/utils/volume_mounts.py" in dockerfile_text
# --------------------------------------------------------------------------- #
# Volume mount-point writability
#
# A fresh Docker named volume inherits ownership from the image directory at its
# mount path on first mount. If that directory is missing or root-owned, the
# volume comes up root-owned and the non-root ``isaaclab`` runtime user cannot
# write it (e.g. ``PermissionError`` creating ``logs/`` or ``omni.datastore``
# lock failures under ``kit/cache``). The image build therefore pre-creates and
# chowns every named-volume mount point, driven by a single source of truth:
# docker-compose.yaml, parsed by docker/utils/volume_mounts.py. These tests
# validate the parser and that each non-root Dockerfile wires it in.
# --------------------------------------------------------------------------- #
NONROOT_VOLUME_DOCKERFILES = {
"Dockerfile.base": "x-default-isaac-lab-volumes",
"Dockerfile.curobo": "x-default-isaac-lab-volumes",
"Dockerfile.kitless": "x-kitless-isaac-lab-volumes",
}
def _volume_mounts_module():
"""Load the parser the image build uses; skip the test if PyYAML is unavailable.
The Docker image build exercises this parser for real, so a test environment
without PyYAML simply skips the parser unit tests rather than failing.
"""
pytest.importorskip("yaml")
return _load_module("volume_mounts", DOCKER_DIR / "utils" / "volume_mounts.py")
def test_compose_volume_targets_parse():
"""The parser returns every ``type: volume`` mount point from docker-compose.yaml.
Includes the directories that triggered the original regression so a compose
edit that drops them is caught here.
"""
targets = _volume_mounts_module().named_volume_targets(DOCKER_DIR / "docker-compose.yaml")
assert targets, "no named-volume targets parsed from docker-compose.yaml"
for required in (
"${DOCKER_ISAACSIM_ROOT_PATH:-/isaac-sim}/kit/cache",
"${DOCKER_ISAACLAB_PATH}/logs",
"${DOCKER_ISAACLAB_PATH}/data_storage",
"${DOCKER_ISAACLAB_PATH}/docs/_build",
):
assert required in targets, f"{required} missing from parsed volume targets: {targets}"
def test_resolved_targets_are_absolute_paths(monkeypatch):
"""With the build's environment, every target resolves to an absolute path."""
monkeypatch.setenv("DOCKER_ISAACSIM_ROOT_PATH", "/isaac-sim")
monkeypatch.setenv("DOCKER_ISAACLAB_PATH", "/workspace/isaaclab")
monkeypatch.setenv("DOCKER_USER_HOME", "/root")
resolved = _volume_mounts_module().resolved_targets(DOCKER_DIR / "docker-compose.yaml")
assert resolved, "no resolved targets"
assert all(p.startswith("/") and "$" not in p for p in resolved), resolved
assert "/isaac-sim/kit/cache" in resolved
assert "/workspace/isaaclab/logs" in resolved
@pytest.mark.parametrize(("dockerfile_name", "volumes_key"), NONROOT_VOLUME_DOCKERFILES.items())
def test_dockerfile_prepares_volume_mounts_from_compose(dockerfile_name: str, volumes_key: str):
"""Each non-root Dockerfile derives its mount points from the parser, with a guard.
Guards the wiring: the build must call ``volume_mounts.py`` under
``set -o pipefail`` (so a parse failure aborts the build) rather than
re-hardcoding the list or silently skipping preparation.
"""
text = _find_dockerfile(dockerfile_name).read_text(encoding="utf-8")
assert "set -o pipefail" in text
assert "docker/utils/volume_mounts.py" in text
assert "chown -R isaaclab:isaaclab ${dirs}" in text
if volumes_key != "x-default-isaac-lab-volumes":
assert f"--volumes_key {volumes_key}" in text
@pytest.mark.parametrize("dockerfile_name", ["Dockerfile.base", "Dockerfile.curobo"])
def test_isaac_sim_dockerfiles_chown_the_omnihub_cache(dockerfile_name: str):
"""OmniHub's cache belongs to the isaac-sim user, so the runtime user must be given it.
Without this, OmniHub cannot write the cache it is pointed at once it is allowed to
start.
"""
dockerfile_text = _find_dockerfile(dockerfile_name).read_text(encoding="utf-8")
chown_block = re.search(r"chown -R isaaclab:isaaclab((?:\s*\\\s*\S+)+)", dockerfile_text)
assert chown_block, f"{dockerfile_name} has no 'chown -R isaaclab:isaaclab' block"
assert "/var/cache/hub" in chown_block.group(1)
@pytest.mark.parametrize("dockerfile_name", ["Dockerfile.base", "Dockerfile.curobo"])
def test_isaac_sim_dockerfiles_let_omnihub_start(dockerfile_name: str):
"""The Isaac Sim image sets ``HUB__ARGS__DETECT_ONLY=true``, forbidding OmniHub to start.
omni.client asks it to launch anyway, so every Kit startup retries ~39 times. The value
must be exactly ``false``: ``--detect-only`` takes a value, so clearing it with
``ENV HUB__ARGS__DETECT_ONLY=`` aborts hub with "a value is required" instead.
"""
dockerfile_text = _find_dockerfile(dockerfile_name).read_text(encoding="utf-8")
assert re.search(r"^ENV HUB__ARGS__DETECT_ONLY=false$", dockerfile_text, re.MULTILINE), (
f"{dockerfile_name} must set 'ENV HUB__ARGS__DETECT_ONLY=false' exactly"
)