Coverage for src / openenv / docker / dockerfile.py: 94.33%
162 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-03-25 13:36 +0000
1"""Dockerfile generation for OpenClawenv."""
3from __future__ import annotations
5import json
6from pathlib import PurePosixPath
8from openenv.core.models import Lockfile, Manifest
9from openenv.core.skills import catalog_install_dir_name, catalog_skill_specs
10from openenv.core.utils import encode_payload, stable_json_dumps
13SKILL_SCANNER_REQUIREMENT = "cisco-ai-skill-scanner==2.0.4"
14OPENCLAW_GATEWAY_RUNTIME_IMAGE = "ghcr.io/openclaw/openclaw:latest"
15DEFAULT_NODE_PACKAGES = ("nodejs", "npm")
16DEFAULT_PYTHON_PACKAGES_APT = ("python3", "python3-pip", "python3-venv", "bash")
17DEFAULT_PYTHON_PACKAGES_APK = ("python3", "py3-pip", "py3-virtualenv", "bash")
18DEFAULT_GLOBAL_NODE_TOOLS = ("agent-browser",)
19DEFAULT_SKILL_SCAN_FORMAT = "summary"
20DEFAULT_SKILL_SCAN_POLICY = "balanced"
21DEFAULT_SKILL_SCAN_FAIL_ON_SEVERITY = "high"
22DEFAULT_OPENCLAW_RUNTIME_USER = "node"
23DEFAULT_OPENCLAW_RUNTIME_HOME = "/home/node"
24ROOT_RUNTIME_USER = "root"
25ROOT_RUNTIME_HOME = "/root"
26DEFAULT_PYTHON_VENV_PATH = "/opt/openclawenv/.venv"
27DEFAULT_OPENCLAW_DOCKER_GPG_FINGERPRINT = "9DC858229FC7DD38854AE2D88D81803C0EBFCD88"
28CLAWHUB_NPX_PACKAGE = "clawhub@latest"
31def render_dockerfile(
32 manifest: Manifest,
33 lockfile: Lockfile,
34 *,
35 raw_manifest_text: str,
36 raw_lock_text: str,
37) -> str:
38 """Render a standalone Dockerfile from a manifest and lockfile."""
39 sandbox_reference = lockfile.base_image["resolved_reference"]
40 payload = _render_payload(
41 manifest,
42 raw_manifest_text=raw_manifest_text,
43 raw_lock_text=raw_lock_text,
44 image_reference="${OPENCLAW_IMAGE}",
45 )
46 payload_b64 = encode_payload(payload)
47 lines: list[str] = [
48 "# syntax=docker/dockerfile:1",
49 f"FROM {OPENCLAW_GATEWAY_RUNTIME_IMAGE}",
50 f'LABEL org.opencontainers.image.title="{_escape_label(manifest.project.name)}"',
51 f'LABEL org.opencontainers.image.version="{_escape_label(manifest.project.version)}"',
52 f'LABEL io.openclawenv.manifest-hash="{lockfile.manifest_hash}"',
53 f'LABEL io.openclawenv.sandbox-image="{_escape_label(sandbox_reference)}"',
54 "ENV PYTHONDONTWRITEBYTECODE=1",
55 "USER root",
56 ]
57 for key, value in sorted(manifest.runtime.env.items()):
58 lines.append(f"ENV {key}={json.dumps(value)}")
59 lines.extend(_package_install_lines(manifest.runtime.system_packages))
60 lines.extend(_python_binary_link_lines())
61 lines.extend(_python_venv_lines())
62 lines.append(
63 "RUN if ! command -v npx >/dev/null 2>&1; then "
64 "printf '%s\\n' '#!/bin/sh' 'exec npm exec --yes -- \"$@\"' "
65 "> /usr/local/bin/npx && chmod +x /usr/local/bin/npx; fi"
66 )
67 requirements = [
68 SKILL_SCANNER_REQUIREMENT,
69 *(_python_package_argument(package) for package in lockfile.python_packages),
70 ]
71 lines.append(
72 "RUN python -m pip install --no-cache-dir " + " ".join(requirements)
73 )
74 node_requirements = _global_node_requirements(lockfile)
75 if node_requirements: 75 ↛ 81line 75 didn't jump to line 81 because the condition on line 75 was always true
76 lines.append(
77 "RUN npm install --global --no-fund --no-update-notifier "
78 + " ".join(node_requirements)
79 )
80 lines.append("RUN agent-browser install")
81 lines.extend(_optional_browser_install_lines())
82 lines.extend(_optional_docker_cli_install_lines())
83 lines.extend(_state_link_lines(manifest))
84 lines.append("RUN mkdir -p /opt/openclawenv")
85 lines.append(
86 'RUN ["python", "-c", '
87 f'{json.dumps(_payload_writer_script(payload_b64))}'
88 "]"
89 )
90 lines.extend(_catalog_skill_install_lines(manifest))
91 lines.extend(_skill_scan_lines(manifest))
92 lines.extend(_runtime_permission_lines(manifest))
93 lines.append(f"USER {_effective_runtime_user(manifest)}")
94 return "\n".join(lines) + "\n"
97def render_runtime_payload(
98 manifest: Manifest,
99 lockfile: Lockfile,
100 *,
101 raw_manifest_text: str,
102 raw_lock_text: str,
103) -> dict[str, object]:
104 """Return the file payload embedded into the runtime image."""
105 return _render_payload(
106 manifest,
107 raw_manifest_text=raw_manifest_text,
108 raw_lock_text=raw_lock_text,
109 image_reference="${OPENCLAW_IMAGE}",
110 )
113def _render_payload(
114 manifest: Manifest,
115 *,
116 raw_manifest_text: str,
117 raw_lock_text: str,
118 image_reference: str,
119) -> dict[str, object]:
120 """Assemble the file payload embedded into the generated Dockerfile."""
121 files = manifest.workspace_files()
122 files[manifest.openclaw.config_path()] = (
123 stable_json_dumps(manifest.openclaw.to_openclaw_json(image_reference), indent=2)
124 + "\n"
125 )
126 files[str(PurePosixPath("/opt/openclawenv") / "openclawenv.toml")] = raw_manifest_text
127 files[str(PurePosixPath("/opt/openclawenv") / "openclawenv.lock")] = raw_lock_text
128 files = dict(sorted(files.items()))
129 return {"directories": _directories_for(files), "files": files}
132def _directories_for(files: dict[str, str]) -> list[str]:
133 """Return the unique parent directories that must exist before payload extraction."""
134 directories = {str(PurePosixPath(path).parent) for path in files}
135 return sorted(directory for directory in directories if directory not in {".", ""})
138def _python_package_argument(package: dict[str, str]) -> str:
139 """Render one locked Python package entry as a `pip install` argument."""
140 if package["kind"] == "direct": 140 ↛ 141line 140 didn't jump to line 141 because the condition on line 140 was never true
141 return package["requirement"]
142 return f"{package['name']}=={package['version']}"
145def _system_packages(packages: list[str]) -> str:
146 """Merge caller packages with default Node requirements while preserving order."""
147 ordered: list[str] = []
148 seen: set[str] = set()
149 for package in [*packages, *DEFAULT_NODE_PACKAGES]:
150 if package not in seen: 150 ↛ 149line 150 didn't jump to line 149 because the condition on line 150 was always true
151 seen.add(package)
152 ordered.append(package)
153 return " ".join(ordered)
156def _package_install_lines(packages: list[str]) -> list[str]:
157 """Render OS package installation commands for both Debian-like and Alpine images."""
158 apt_packages = _system_packages([*packages, *DEFAULT_PYTHON_PACKAGES_APT])
159 apk_packages = _apk_system_packages(packages)
160 return [
161 "RUN if command -v apt-get >/dev/null 2>&1; then "
162 "apt-get update && apt-get install -y --no-install-recommends "
163 f"{apt_packages} && rm -rf /var/lib/apt/lists/*; "
164 "elif command -v apk >/dev/null 2>&1; then "
165 f"apk add --no-cache {apk_packages}; "
166 "else echo 'Unsupported base image: no apt-get or apk available.' >&2; exit 1; fi"
167 ]
170def _apk_system_packages(packages: list[str]) -> str:
171 """Return the deduplicated package list used by the Alpine installation branch."""
172 ordered: list[str] = []
173 seen: set[str] = set()
174 for package in [*packages, *DEFAULT_NODE_PACKAGES, *DEFAULT_PYTHON_PACKAGES_APK]:
175 if package not in seen: 175 ↛ 174line 175 didn't jump to line 174 because the condition on line 175 was always true
176 seen.add(package)
177 ordered.append(package)
178 return " ".join(ordered)
181def _python_binary_link_lines() -> list[str]:
182 """Render compatibility symlinks so `python` and `pip` are always available."""
183 return [
184 "RUN mkdir -p /usr/local/bin && "
185 "if ! command -v python >/dev/null 2>&1 && command -v python3 >/dev/null 2>&1; then "
186 'ln -sf "$(command -v python3)" /usr/local/bin/python; fi',
187 "RUN mkdir -p /usr/local/bin && "
188 "if ! command -v pip >/dev/null 2>&1 && command -v pip3 >/dev/null 2>&1; then "
189 'ln -sf "$(command -v pip3)" /usr/local/bin/pip; fi',
190 ]
193def _python_venv_lines() -> list[str]:
194 """Create and activate an image-local virtualenv for Python package installation."""
195 return [
196 "RUN python -m venv "
197 f'"{DEFAULT_PYTHON_VENV_PATH}"'
198 " 2>/dev/null || python -m virtualenv "
199 f'"{DEFAULT_PYTHON_VENV_PATH}"',
200 f'ENV VIRTUAL_ENV="{DEFAULT_PYTHON_VENV_PATH}"',
201 f'ENV PATH="{DEFAULT_PYTHON_VENV_PATH}/bin:$PATH"',
202 ]
205def _optional_browser_install_lines() -> list[str]:
206 """Render opt-in Playwright browser installation steps for the wrapper image."""
207 return [
208 'ARG OPENCLAW_INSTALL_BROWSER=""',
209 "RUN if [ -n \"$OPENCLAW_INSTALL_BROWSER\" ]; then "
210 "if ! command -v apt-get >/dev/null 2>&1; then "
211 "echo 'OPENCLAW_INSTALL_BROWSER requires an apt-get based image.' >&2; exit 1; "
212 "fi && "
213 "apt-get update && "
214 "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends xvfb && "
215 "mkdir -p /home/node/.cache/ms-playwright && "
216 "PLAYWRIGHT_BROWSERS_PATH=/home/node/.cache/ms-playwright "
217 "node /app/node_modules/playwright-core/cli.js install --with-deps chromium && "
218 "chown -R node:node /home/node/.cache/ms-playwright; "
219 "fi",
220 ]
223def _optional_docker_cli_install_lines() -> list[str]:
224 """Render opt-in Docker CLI installation steps for sandbox-enabled deployments."""
225 return [
226 'ARG OPENCLAW_INSTALL_DOCKER_CLI=""',
227 f'ARG OPENCLAW_DOCKER_GPG_FINGERPRINT="{DEFAULT_OPENCLAW_DOCKER_GPG_FINGERPRINT}"',
228 "RUN if [ -n \"$OPENCLAW_INSTALL_DOCKER_CLI\" ]; then "
229 "if ! command -v apt-get >/dev/null 2>&1; then "
230 "echo 'OPENCLAW_INSTALL_DOCKER_CLI requires an apt-get based image.' >&2; exit 1; "
231 "fi && "
232 "apt-get update && "
233 "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "
234 "ca-certificates curl gnupg && "
235 "install -m 0755 -d /etc/apt/keyrings && "
236 "curl -fsSL https://download.docker.com/linux/debian/gpg -o /tmp/docker.gpg.asc && "
237 "expected_fingerprint=\"$(printf '%s' \"$OPENCLAW_DOCKER_GPG_FINGERPRINT\" | tr '[:lower:]' '[:upper:]' | tr -d '[:space:]')\" && "
238 "actual_fingerprint=\"$(gpg --batch --show-keys --with-colons /tmp/docker.gpg.asc | awk -F: '$1 == \\\"fpr\\\" { print toupper($10); exit }')\" && "
239 "if [ -z \"$actual_fingerprint\" ] || [ \"$actual_fingerprint\" != \"$expected_fingerprint\" ]; then "
240 "echo \"ERROR: Docker apt key fingerprint mismatch (expected $expected_fingerprint, got ${actual_fingerprint:-<empty>})\" >&2; exit 1; "
241 "fi && "
242 "gpg --dearmor -o /etc/apt/keyrings/docker.gpg /tmp/docker.gpg.asc && "
243 "rm -f /tmp/docker.gpg.asc && "
244 "chmod a+r /etc/apt/keyrings/docker.gpg && "
245 "printf 'deb [arch=%s signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/debian bookworm stable\\n' "
246 "\"$(dpkg --print-architecture)\" > /etc/apt/sources.list.d/docker.list && "
247 "apt-get update && "
248 "DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends "
249 "docker-ce-cli docker-compose-plugin; "
250 "fi",
251 ]
254def _global_node_requirements(lockfile: Lockfile) -> list[str]:
255 """Return the default and user-requested global Node tools for the image."""
256 ordered = list(DEFAULT_GLOBAL_NODE_TOOLS)
257 seen = set(ordered)
258 for package in lockfile.node_packages:
259 requirement = package["requirement"]
260 if requirement not in seen: 260 ↛ 258line 260 didn't jump to line 258 because the condition on line 260 was always true
261 seen.add(requirement)
262 ordered.append(requirement)
263 return ordered
266def _payload_writer_script(payload_b64: str) -> str:
267 """Build the inline Python extraction script embedded in the Dockerfile."""
268 return (
269 "import base64, json, pathlib; "
270 f"payload=json.loads(base64.b64decode({payload_b64!r}).decode('utf-8')); "
271 "[pathlib.Path(directory).mkdir(parents=True, exist_ok=True) "
272 "for directory in payload['directories']]; "
273 "[pathlib.Path(path).write_text(content, encoding='utf-8') "
274 "for path, content in payload['files'].items()]"
275 )
278def _skill_scan_lines(manifest: Manifest) -> list[str]:
279 """Render build-time skill scanning commands when the manifest contains skills."""
280 if not manifest.skills:
281 return []
282 skills_root = PurePosixPath(manifest.openclaw.workspace) / "skills"
283 return [
284 f"ARG OPENCLAWENV_SKILL_SCAN_FORMAT={DEFAULT_SKILL_SCAN_FORMAT}",
285 f"ARG OPENCLAWENV_SKILL_SCAN_POLICY={DEFAULT_SKILL_SCAN_POLICY}",
286 "ARG OPENCLAWENV_SKILL_SCAN_FAIL_ON_SEVERITY="
287 f"{DEFAULT_SKILL_SCAN_FAIL_ON_SEVERITY}",
288 "RUN if [ -d "
289 f'"{skills_root}"'
290 " ] && find "
291 f'"{skills_root}"'
292 " -mindepth 1 -maxdepth 1 -type d -print -quit | grep -q .; then "
293 "skill-scanner scan-all "
294 f'"{skills_root}"'
295 ' --recursive --check-overlap --format "$OPENCLAWENV_SKILL_SCAN_FORMAT"'
296 ' --policy "$OPENCLAWENV_SKILL_SCAN_POLICY"'
297 ' --fail-on-severity "$OPENCLAWENV_SKILL_SCAN_FAIL_ON_SEVERITY"; '
298 "else echo 'No skills to scan; skipping skill-scanner.'; fi",
299 ]
302def _state_link_lines(manifest: Manifest) -> list[str]:
303 """Render symlinks that expose the OpenClaw state directory under common home paths."""
304 state_dir = manifest.openclaw.state_dir
305 lines = [
306 "RUN mkdir -p "
307 f'"{state_dir}" "{ROOT_RUNTIME_HOME}" "{DEFAULT_OPENCLAW_RUNTIME_HOME}" '
308 f'&& ln -sfn "{state_dir}" "{ROOT_RUNTIME_HOME}/.openclaw" '
309 f'&& ln -sfn "{state_dir}" "{DEFAULT_OPENCLAW_RUNTIME_HOME}/.openclaw"'
310 ]
311 if manifest.runtime.user not in {"root", DEFAULT_OPENCLAW_RUNTIME_USER}:
312 lines.append(
313 "RUN if id -u "
314 f'"{manifest.runtime.user}"'
315 " >/dev/null 2>&1; then mkdir -p "
316 f'"/home/{manifest.runtime.user}" && ln -sfn "{state_dir}" '
317 f'"/home/{manifest.runtime.user}/.openclaw"; fi'
318 )
319 return lines
322def _catalog_skill_install_lines(manifest: Manifest) -> list[str]:
323 """Render build-time installation steps for skills sourced from an external catalog."""
324 workdir = manifest.openclaw.workspace
325 lines: list[str] = []
326 for skill_name, source in catalog_skill_specs(manifest.skills):
327 skill_path = PurePosixPath(workdir) / "skills" / skill_name
328 installed_name = catalog_install_dir_name(source)
329 lines.append(
330 "RUN "
331 + _catalog_skill_install_script(
332 source=source,
333 workdir=workdir,
334 skill_path=skill_path,
335 installed_name=installed_name,
336 )
337 )
338 return lines
341def _runtime_permission_lines(manifest: Manifest) -> list[str]:
342 """Render the final directory ownership adjustments for the OpenClaw runtime user."""
343 runtime_user = _effective_runtime_user(manifest)
344 command = "RUN mkdir -p " f'"{manifest.runtime.workdir}"'
345 if runtime_user != ROOT_RUNTIME_USER:
346 command += (
347 " && if id -u "
348 f'"{runtime_user}"'
349 " >/dev/null 2>&1; then chown -R "
350 f"{runtime_user}:{runtime_user} "
351 f'"{manifest.openclaw.state_dir}" "{manifest.runtime.workdir}"; fi'
352 )
353 return [command]
356def _effective_runtime_user(manifest: Manifest) -> str:
357 """Return the runtime user supported by the generated OpenClaw wrapper image."""
358 if manifest.runtime.user.strip().casefold() == ROOT_RUNTIME_USER:
359 return ROOT_RUNTIME_USER
360 return DEFAULT_OPENCLAW_RUNTIME_USER
363def _clawhub_install_command(source: str, workdir: str) -> str:
364 """Return the ClawHub install command used for catalog-backed skills."""
365 workdir_json = json.dumps(workdir)
366 source_json = json.dumps(source)
367 return (
368 "npx --yes "
369 f"{CLAWHUB_NPX_PACKAGE} install {source_json} --workdir {workdir_json} "
370 "--force --no-input"
371 )
374def _catalog_skill_install_script(
375 *,
376 source: str,
377 workdir: str,
378 skill_path: PurePosixPath,
379 installed_name: str,
380) -> str:
381 """Return a resilient shell script that installs a catalog skill or keeps its placeholder."""
382 skill_md = skill_path / "SKILL.md"
383 install_root_var = "openclawenv_install_root"
384 installed_root = f'${install_root_var}/skills/{installed_name}'
385 cleanup_targets = _rm_target_arguments(skill_path, PurePosixPath(workdir) / "skills" / installed_name)
386 skills_root = json.dumps(str(PurePosixPath(workdir) / "skills"))
387 skill_md_json = json.dumps(str(skill_md))
388 placeholder_marker = json.dumps("This skill is referenced from an external catalog.")
389 install_command = _clawhub_install_command(source, f"${install_root_var}")
390 install_warning = json.dumps(
391 f"WARNING: ClawHub install for {source!r} did not materialize an expected skill directory; "
392 f"keeping placeholder at {skill_path}."
393 )
394 install_error = json.dumps(
395 f"ERROR: ClawHub install for {source!r} did not materialize an expected skill directory and "
396 f"no placeholder exists at {skill_path}."
397 )
398 source_warning = json.dumps(
399 f"WARNING: ClawHub skill source {source!r} was not found; keeping placeholder at {skill_path}."
400 )
401 source_error = json.dumps(
402 f"ERROR: ClawHub skill source {source!r} was not found and no placeholder exists at {skill_path}."
403 )
404 return (
405 f"mkdir -p {skills_root} && "
406 f"if [ ! -f {skill_md_json} ] || grep -qF {placeholder_marker} {skill_md_json}; then "
407 f'{install_root_var}="$(mktemp -d)" && '
408 f"if ({install_command}); then "
409 f'if [ -d "{installed_root}" ]; then rm -rf {cleanup_targets} && mv "{installed_root}" "{skill_path}"; '
410 f"elif [ -f {skill_md_json} ]; then echo {install_warning} >&2; "
411 f"else echo {install_error} >&2; exit 1; fi; "
412 f"else if [ -f {skill_md_json} ]; then echo {source_warning} >&2; "
413 f"else echo {source_error} >&2; exit 1; fi; fi "
414 f'&& rm -rf "${install_root_var}"; '
415 "fi"
416 )
419def _clawhub_post_install_move(skill_path: PurePosixPath, installed_path: PurePosixPath) -> str:
420 """Return an optional rename step when ClawHub's directory differs from the wrapper name."""
421 if skill_path == installed_path:
422 return ""
423 return (
424 " && if [ -d "
425 f'"{installed_path}"'
426 " ]; then mv "
427 f'"{installed_path}" "{skill_path}"; '
428 "fi"
429 )
432def _rm_target_arguments(skill_path: PurePosixPath, installed_path: PurePosixPath) -> str:
433 """Render unique `rm -rf` target arguments for pre-install cleanup."""
434 targets: list[str] = [f'"{skill_path}"']
435 if installed_path != skill_path:
436 targets.append(f'"{installed_path}"')
437 return " ".join(targets)
440def _escape_label(value: str) -> str:
441 """Escape Docker label values so they can be embedded safely in the Dockerfile."""
442 return value.replace("\\", "\\\\").replace('"', '\\"')