diff --git a/Dockerfile b/Dockerfile index cba29fb48d0..176bc2b0233 100644 --- a/Dockerfile +++ b/Dockerfile @@ -648,6 +648,16 @@ RUN if [ "$NEMOCLAW_DARWIN_VM_COMPAT" = "1" ]; then \ chmod a+rw /sandbox/.nemoclaw/config.json; \ fi +# Health check: poll the gateway's /health endpoint so Docker (and Compose) +# can detect and restart unhealthy containers in standalone deployments. +# Ref: https://github.com/NVIDIA/NemoClaw/issues/1430 +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD port="${NEMOCLAW_DASHBOARD_PORT:-${OPENCLAW_GATEWAY_PORT:-}}"; \ + if [ -z "$port" ]; then \ + port="$(python3 -c 'import os; from urllib.parse import urlparse; raw = os.environ.get("CHAT_UI_URL") or "http://127.0.0.1:18789"; raw = raw if "://" in raw else "http://" + raw; u = urlparse(raw); print(u.port or 18789)' 2>/dev/null || printf '18789')"; \ + fi; \ + curl -sf "http://127.0.0.1:${port}/health" + # Entrypoint runs as root to start the gateway as the gateway user, # then drops to sandbox for agent commands. See nemoclaw-start.sh. ENTRYPOINT ["/usr/local/bin/nemoclaw-start"] diff --git a/Dockerfile.base b/Dockerfile.base index 4d920cb614b..01580bb971a 100644 --- a/Dockerfile.base +++ b/Dockerfile.base @@ -209,3 +209,11 @@ RUN openclaw plugins install '@tencent-weixin/openclaw-weixin@2.4.2' --pin \ # hadolint ignore=DL3002 USER root WORKDIR / + +# Baseline health check. The base image runs no service, so this only +# verifies the Node.js runtime is functional. Child images that expose +# a service (e.g. the production Dockerfile's gateway) MUST override +# this with a service-specific probe; otherwise an unresponsive service +# will still report healthy. +HEALTHCHECK --interval=30s --timeout=5s --start-period=45s --retries=3 \ + CMD node -e "process.exit(0)" diff --git a/test/Dockerfile.sandbox b/test/Dockerfile.sandbox index 1cde0f1b5cf..d9ecf18f09d 100644 --- a/test/Dockerfile.sandbox +++ b/test/Dockerfile.sandbox @@ -105,4 +105,9 @@ RUN mkdir -p /sandbox/openclaw-state/extensions /sandbox/openclaw-state/skills / && printf '%s\n' '---' 'name: override-hook' 'description: Override hook fixture' '---' > /sandbox/openclaw-state/hooks/demo-hook/HOOK.md \ && printf '%s\n' 'export default async function overrideHook() {}' > /sandbox/openclaw-state/hooks/demo-hook/handler.js +# Test image: no long-running service, so just verify the runtime works. +# Overrides any inherited HEALTHCHECK to avoid false-positive probes. +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD node -e "process.exit(0)" + ENTRYPOINT ["/bin/bash"] diff --git a/test/sandbox-provisioning.test.ts b/test/sandbox-provisioning.test.ts index dcdef43c5d1..4eef9378ad5 100644 --- a/test/sandbox-provisioning.test.ts +++ b/test/sandbox-provisioning.test.ts @@ -60,6 +60,40 @@ function dockerRunCommandBetween( .replace(/\\\n/g, " "); } +function dockerHealthCommandBetween( + dockerfile: string, + startMarker: string, + endMarker?: string, +): string { + const start = dockerfile.indexOf(startMarker); + const end = endMarker ? dockerfile.indexOf(endMarker, start) : dockerfile.length; + if (start === -1 || end === -1 || end <= start) { + throw new Error(`Expected Dockerfile health check after ${startMarker}`); + } + const healthOffset = dockerfile.slice(start, end).search(/^HEALTHCHECK\b/m); + const healthIndex = healthOffset === -1 ? -1 : start + healthOffset; + if (healthIndex === -1) { + throw new Error(`Expected HEALTHCHECK instruction after ${startMarker}`); + } + const healthLines: string[] = []; + for (const line of dockerfile.slice(healthIndex, end).split("\n")) { + healthLines.push(line); + if (!line.trimEnd().endsWith("\\")) { + break; + } + } + const lastLine = healthLines[healthLines.length - 1]?.trimEnd() ?? ""; + if (lastLine.endsWith("\\")) { + throw new Error(`Expected complete HEALTHCHECK instruction after ${startMarker}`); + } + const instruction = healthLines.join("\n").trim().replace(/\\\n/g, " "); + const command = instruction.match(/(?:^|\s)CMD\s+([\s\S]+)$/)?.[1]; + if (!command) { + throw new Error(`Expected shell-form HEALTHCHECK CMD after ${startMarker}`); + } + return command.trim(); +} + function runDockerShell(command: string, sandboxRoot: string) { const logPath = path.join(sandboxRoot, "calls.log"); fs.rmSync(logPath, { force: true }); @@ -78,7 +112,12 @@ function runDockerShell(command: string, sandboxRoot: string) { return { result, calls }; } -function runLoggedDockerShell(command: string, tmp: string, functionDefs: string[] = []) { +function runLoggedDockerShell( + command: string, + tmp: string, + functionDefs: string[] = [], + env: Record = {}, +) { const logPath = path.join(tmp, "calls.log"); fs.rmSync(logPath, { force: true }); const script = [ @@ -90,11 +129,111 @@ function runLoggedDockerShell(command: string, tmp: string, functionDefs: string ].join("\n"); const scriptPath = path.join(tmp, "run-docker-block.sh"); fs.writeFileSync(scriptPath, script, { mode: 0o700 }); - const result = spawnSync("bash", [scriptPath], { encoding: "utf-8", timeout: 5000 }); + const childEnv = { ...process.env }; + for (const [key, value] of Object.entries(env)) { + if (value === undefined) { + delete childEnv[key]; + } else { + childEnv[key] = value; + } + } + const result = spawnSync("bash", [scriptPath], { + encoding: "utf-8", + env: childEnv, + timeout: 5000, + }); const calls = fs.existsSync(logPath) ? fs.readFileSync(logPath, "utf-8") : ""; return { result, calls }; } +describe("sandbox provisioning: image health checks (#1430)", () => { + it.each([ + ["default dashboard URL", {}, "http://127.0.0.1:18789/health"], + [ + "CHAT_UI_URL with scheme", + { CHAT_UI_URL: "http://127.0.0.1:19000" }, + "http://127.0.0.1:19000/health", + ], + [ + "CHAT_UI_URL without scheme", + { CHAT_UI_URL: "remote-host:19111" }, + "http://127.0.0.1:19111/health", + ], + [ + "OPENCLAW_GATEWAY_PORT", + { CHAT_UI_URL: "http://127.0.0.1:19000", OPENCLAW_GATEWAY_PORT: "19333" }, + "http://127.0.0.1:19333/health", + ], + [ + "NEMOCLAW_DASHBOARD_PORT override", + { + CHAT_UI_URL: "http://127.0.0.1:19000", + NEMOCLAW_DASHBOARD_PORT: "19222", + OPENCLAW_GATEWAY_PORT: "19333", + }, + "http://127.0.0.1:19222/health", + ], + ])("routes production gateway probe through %s", (_label, env, expectedUrl) => { + const dockerfile = fs.readFileSync(DOCKERFILE, "utf-8"); + const command = dockerHealthCommandBetween( + dockerfile, + "# Health check: poll the gateway's /health endpoint", + "# Entrypoint runs as root", + ); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-health-probe-")); + + try { + const probe = runLoggedDockerShell( + command, + tmp, + ['curl() { printf "%s\\n" "$*" >> "$call_log"; }'], + { + NEMOCLAW_DASHBOARD_PORT: undefined, + OPENCLAW_GATEWAY_PORT: undefined, + CHAT_UI_URL: undefined, + ...env, + }, + ); + + expect(probe.result.status).toBe(0); + expect(probe.calls).toContain("-sf"); + expect(probe.calls).toContain(expectedUrl); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); + + it.each([ + [ + "base image", + DOCKERFILE_BASE, + "# Baseline health check.", + undefined, + ], + [ + "test image", + DOCKERFILE_SANDBOX, + "# Test image: no long-running service", + "ENTRYPOINT", + ], + ])("keeps %s non-service probe runtime-only", (_label, imagePath, startMarker, endMarker) => { + const imageDefinition = fs.readFileSync(imagePath, "utf-8"); + const command = dockerHealthCommandBetween(imageDefinition, startMarker, endMarker); + const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-runtime-probe-")); + + try { + const probe = runLoggedDockerShell(command, tmp, [ + 'curl() { printf "%s\\n" "$*" >> "$call_log"; return 42; }', + ]); + + expect(probe.result.status).toBe(0); + expect(probe.calls).toBe(""); + } finally { + fs.rmSync(tmp, { recursive: true, force: true }); + } + }); +}); + describe("sandbox provisioning: unified .openclaw layout (#2227)", () => { it("provisions unified mutable .openclaw layout and trusted rc shims", () => { const dockerfile = fs.readFileSync(DOCKERFILE_BASE, "utf-8");