From 3736d7b60be1e2a784785c55875fb2cd94fb7e55 Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Tue, 26 May 2026 23:56:48 +0200 Subject: [PATCH] fix(docker): require bounded e2e docker commands --- scripts/lib/docker-e2e-container.sh | 27 ++- scripts/lib/docker-e2e-package.sh | 15 +- scripts/test-projects.test-support.mjs | 3 + test/scripts/docker-build-helper.test.ts | 243 ++++++++++++++++++++++- 4 files changed, 275 insertions(+), 13 deletions(-) diff --git a/scripts/lib/docker-e2e-container.sh b/scripts/lib/docker-e2e-container.sh index 9d1992f4588..9d20d21e15c 100644 --- a/scripts/lib/docker-e2e-container.sh +++ b/scripts/lib/docker-e2e-container.sh @@ -3,18 +3,29 @@ # Shared helpers for Docker E2E scripts that keep a named container running # while polling readiness from the host. +docker_e2e_timeout_bin() { + if command -v timeout >/dev/null 2>&1; then + printf '%s\n' timeout + elif command -v gtimeout >/dev/null 2>&1; then + printf '%s\n' gtimeout + else + return 1 + fi +} + docker_e2e_timeout_cmd() { local timeout_value="$1" shift - if command -v timeout >/dev/null 2>&1; then - if timeout --kill-after=1s 1s true >/dev/null 2>&1; then - timeout --kill-after=30s "$timeout_value" "$@" - else - timeout "$timeout_value" "$@" - fi - return + local timeout_bin + if ! timeout_bin="$(docker_e2e_timeout_bin)"; then + echo "timeout command not found; cannot bound Docker command after ${timeout_value}" >&2 + return 127 + fi + if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then + "$timeout_bin" --kill-after=30s "$timeout_value" "$@" + else + "$timeout_bin" "$timeout_value" "$@" fi - "$@" } docker_e2e_docker_cmd() { diff --git a/scripts/lib/docker-e2e-package.sh b/scripts/lib/docker-e2e-package.sh index b2999fdc497..c374f0018f2 100644 --- a/scripts/lib/docker-e2e-package.sh +++ b/scripts/lib/docker-e2e-package.sh @@ -20,15 +20,22 @@ if ! declare -F docker_e2e_docker_run_cmd >/dev/null 2>&1; then return fi local timeout_value="${DOCKER_COMMAND_TIMEOUT:-${OPENCLAW_DOCKER_E2E_RUN_TIMEOUT:-3600s}}" + local timeout_bin="" if command -v timeout >/dev/null 2>&1; then - if timeout --kill-after=1s 1s true >/dev/null 2>&1; then - timeout --kill-after=30s "$timeout_value" docker "$@" + timeout_bin="timeout" + elif command -v gtimeout >/dev/null 2>&1; then + timeout_bin="gtimeout" + fi + if [ -n "$timeout_bin" ]; then + if "$timeout_bin" --kill-after=1s 1s true >/dev/null 2>&1; then + "$timeout_bin" --kill-after=30s "$timeout_value" docker "$@" else - timeout "$timeout_value" docker "$@" + "$timeout_bin" "$timeout_value" docker "$@" fi return fi - docker "$@" + echo "timeout command not found; cannot bound Docker run after ${timeout_value}" >&2 + return 127 } fi diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 3c649d062c2..5a0c5a86732 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -360,6 +360,8 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([ "scripts/deadcode-unused-files.allowlist.mjs", ["test/scripts/check-deadcode-unused-files.test.ts"], ], + ["scripts/lib/docker-e2e-container.sh", ["test/scripts/docker-build-helper.test.ts"]], + ["scripts/lib/docker-e2e-package.sh", ["test/scripts/docker-build-helper.test.ts"]], ["scripts/lib/live-docker-stage.sh", ["test/scripts/live-docker-stage.test.ts"]], ["scripts/lib/openclaw-test-state.mjs", ["test/scripts/openclaw-test-state.test.ts"]], ["scripts/lib/vitest-local-scheduling.mjs", ["test/scripts/vitest-local-scheduling.test.ts"]], @@ -420,6 +422,7 @@ const TOOLING_TEST_TARGETS = new Map([ ["test/scripts/check-deadcode-unused-files.test.ts"], ], ["test/scripts/ci-docker-pull-retry.test.ts", ["test/scripts/ci-docker-pull-retry.test.ts"]], + ["test/scripts/docker-build-helper.test.ts", ["test/scripts/docker-build-helper.test.ts"]], ["test/scripts/live-docker-stage.test.ts", ["test/scripts/live-docker-stage.test.ts"]], ["test/scripts/openclaw-test-state.test.ts", ["test/scripts/openclaw-test-state.test.ts"]], [ diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 60ac87c5b34..ca16b871d81 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -1,5 +1,13 @@ import { execFileSync } from "node:child_process"; -import { mkdtempSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { + chmodSync, + mkdtempSync, + mkdirSync, + readdirSync, + readFileSync, + rmSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { describe, expect, it } from "vitest"; @@ -244,6 +252,239 @@ grep -q '^pull openclaw-reuse-image$' "$TMPDIR/docker-seen" } }); + it("fails Docker commands fast when timeout is unavailable", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-timeout-required-")); + + try { + mkdirSync(join(workDir, "bin")); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +export PATH="$TMPDIR/bin" +export DOCKER_COMMAND_TIMEOUT=7s + +docker() { + printf "%s\\n" "$*" >"$TMPDIR/docker-seen" +} +export -f docker + +source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh" + +set +e +docker_e2e_docker_cmd ps 2>"$TMPDIR/stderr" +status="$?" +set -e + +stderr="$(<"$TMPDIR/stderr")" +[[ "$status" = "127" ]] +[[ "$stderr" = *"timeout command not found; cannot bound Docker command after 7s"* ]] +[[ ! -e "$TMPDIR/docker-seen" ]] +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("uses plain timeout when kill-after is unsupported", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-plain-timeout-")); + + try { + const binDir = join(workDir, "bin"); + mkdirSync(binDir); + writeFileSync( + join(binDir, "timeout"), + `#!/bin/bash +set -euo pipefail +if [[ "$1" = "--kill-after=1s" ]]; then + exit 1 +fi +printf 'plain:%s|%s\\n' "$1" "\${*:2}" >>"$TMPDIR/timeout-seen" +shift +"$@" +`, + ); + chmodSync(join(binDir, "timeout"), 0o755); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +export PATH="$TMPDIR/bin:$PATH" +export DOCKER_COMMAND_TIMEOUT=9s + +docker() { + printf "%s\\n" "$*" >>"$TMPDIR/docker-seen" +} +export -f docker + +source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh" + +docker_e2e_docker_cmd image inspect demo + +grep -q '^plain:9s|docker image inspect demo$' "$TMPDIR/timeout-seen" +grep -q '^image inspect demo$' "$TMPDIR/docker-seen" +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("uses gtimeout when timeout is unavailable", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-gtimeout-")); + + try { + const binDir = join(workDir, "bin"); + mkdirSync(binDir); + writeFileSync( + join(binDir, "gtimeout"), + `#!/bin/bash +set -euo pipefail +if [[ "$1" = "--kill-after=1s" ]]; then + exit 0 +fi +printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen" +shift 2 +"$@" +`, + ); + chmodSync(join(binDir, "gtimeout"), 0o755); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +export PATH="$TMPDIR/bin" +export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=13s + +docker() { + printf "%s\\n" "$*" >>"$TMPDIR/docker-seen" +} +export -f docker + +source "$ROOT_DIR/scripts/lib/docker-e2e-container.sh" + +docker_e2e_docker_run_cmd run demo + +[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 13s|docker run demo" ]] +[[ "$(<"$TMPDIR/docker-seen")" = "run demo" ]] +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("keeps package-backed Docker runs bounded without the shared timeout helper", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-timeout-required-")); + + try { + mkdirSync(join(workDir, "bin")); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +export PATH="$TMPDIR/bin" +export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=11s + +dirname() { + /usr/bin/dirname "$@" +} + +docker_e2e_docker_cmd() { + return 0 +} + +docker() { + printf "%s\\n" "$*" >"$TMPDIR/docker-seen" +} +export -f docker_e2e_docker_cmd docker + +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +set +e +docker_e2e_docker_run_cmd run demo 2>"$TMPDIR/stderr" +status="$?" +set -e + +stderr="$(<"$TMPDIR/stderr")" +[[ "$status" = "127" ]] +[[ "$stderr" = *"timeout command not found; cannot bound Docker run after 11s"* ]] +[[ ! -e "$TMPDIR/docker-seen" ]] +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + + it("uses gtimeout for package-backed Docker runs without the shared timeout helper", () => { + const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-package-gtimeout-")); + + try { + const binDir = join(workDir, "bin"); + mkdirSync(binDir); + writeFileSync( + join(binDir, "gtimeout"), + `#!/bin/bash +set -euo pipefail +if [[ "$1" = "--kill-after=1s" ]]; then + exit 0 +fi +printf 'gtimeout:%s %s|%s\\n' "$1" "$2" "\${*:3}" >>"$TMPDIR/timeout-seen" +shift 2 +"$@" +`, + ); + chmodSync(join(binDir, "gtimeout"), 0o755); + const rootDir = process.cwd(); + const script = ` +set -euo pipefail +ROOT_DIR=${shellQuote(rootDir)} +TMPDIR=${shellQuote(workDir)} +export ROOT_DIR TMPDIR +export PATH="$TMPDIR/bin" +export OPENCLAW_DOCKER_E2E_RUN_TIMEOUT=15s + +dirname() { + /usr/bin/dirname "$@" +} + +docker_e2e_docker_cmd() { + return 0 +} + +docker() { + printf "%s\\n" "$*" >>"$TMPDIR/docker-seen" +} +export -f docker_e2e_docker_cmd docker + +source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" + +docker_e2e_docker_run_cmd run demo + +[[ "$(<"$TMPDIR/timeout-seen")" = "gtimeout:--kill-after=30s 15s|docker run demo" ]] +[[ "$(<"$TMPDIR/docker-seen")" = "run demo" ]] +`; + + execFileSync("bash", ["-lc", script], { encoding: "utf8" }); + } finally { + rmSync(workDir, { recursive: true, force: true }); + } + }); + it("removes functional Docker build package inputs after the build", () => { const workDir = mkdtempSync(join(tmpdir(), "openclaw-docker-build-cleanup-"));