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

1"""Dockerfile generation for OpenClawenv.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import PurePosixPath 

7 

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 

11 

12 

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" 

29 

30 

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" 

95 

96 

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 ) 

111 

112 

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} 

130 

131 

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 {".", ""}) 

136 

137 

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']}" 

143 

144 

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) 

154 

155 

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 ] 

168 

169 

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) 

179 

180 

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 ] 

191 

192 

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 ] 

203 

204 

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 ] 

221 

222 

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 ] 

252 

253 

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 

264 

265 

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 ) 

276 

277 

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 ] 

300 

301 

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 

320 

321 

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 

339 

340 

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] 

354 

355 

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 

361 

362 

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 ) 

372 

373 

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 ) 

417 

418 

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 ) 

430 

431 

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) 

438 

439 

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('"', '\\"')