openclaw/scripts/lib/docker-e2e-package.sh
Vincent Koc 0a6f1fa28b
fix(doctor): explain update lint supervisor refusals (#156547)
* fix(doctor): explain update lint supervisor refusals

* test(gateway): complete placement read policy fixture

Supply the policyConfig required by SessionRowReadView after scoped session authorization was introduced. This restores the gateway-root type check without weakening the production contract or changing lifecycle assertions.

(cherry picked from commit 0901260323)

* test(agents): join owned cleanup before fixture assertions

Completion now crosses asynchronous SQLite work, but two steer-restart
fixture cases still polled callbacks every millisecond with a one-second
deadline. The assertion could expire while valid completion work remained
admitted; the existing teardown join then observed the missing announcement.
Join the existing lifecycle and cleanup roots before the unchanged assertions,
restore observers in finally, and remove the timed-poll helper.

The original CI failure and two local failures reproduced the race; a local
diagnostic observed zero announcements before owner settlement and one after.
Natural isolated and loaded Linux baselines passed, so no natural Linux
before-failure is claimed. After the repair, the full file passed 20 times
and its exact 86-file Linux child shard passed three times. Changed checks
and P2 review passed. The existing 22-test file measured 27.99 seconds wall
with one worker; no new tests, retries, or timeouts were added.

(cherry picked from commit 1f067fa03d)

* test(update): arm Doctor refusal at the worker entry

Use the existing survivor preload path so inspection budget admission cannot skip the diagnostic refusal fixture. Preserve refused update and process evidence separately from the healthy retry through the maintained diagnostics publisher.

Retain the original successful-update fixture failure as evidence; production Doctor semantics remain unchanged.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

* test(e2e): reap orphaned upgrade survivor fixture children

Start both survivor containers with Docker init so orphaned worker children
are reaped by the namespace owner. Retain process parentage and PID 1 identity
in the refusal witnesses, and include HostConfig.Init in failure diagnostics.
Keep the supervisor refusal and completed-cleanup assertions unchanged.

Co-authored-by: Vincent Koc <vincentkoc@ieee.org>

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
2026-09-24 22:44:37 +00:00

518 lines
17 KiB
Bash

#!/usr/bin/env bash
#
# Shared package helpers for Docker E2E scripts.
# Builds or resolves one OpenClaw npm tarball and exposes mount/build-context
# helpers so Docker lanes test the package artifact instead of repo sources.
DOCKER_E2E_PACKAGE_LIB_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT_DIR="${ROOT_DIR:-$(cd "$DOCKER_E2E_PACKAGE_LIB_DIR/../.." && pwd)}"
if ! declare -F run_logged >/dev/null 2>&1; then
source "$DOCKER_E2E_PACKAGE_LIB_DIR/docker-e2e-logs.sh"
fi
if ! declare -F docker_e2e_docker_cmd >/dev/null 2>&1 || \
! declare -F docker_e2e_docker_run_cmd >/dev/null 2>&1; then
source "$DOCKER_E2E_PACKAGE_LIB_DIR/docker-e2e-container.sh"
fi
docker_e2e_abs_path() {
local file="$1"
(cd "$(dirname "$file")" && printf '%s/%s\n' "$(pwd)" "$(basename "$file")")
}
docker_e2e_restore_package_dist_from_image() (
local image="$1"
local ai_backup_dir=""
local ai_dist_dir=""
local ai_dist_installed=0
local ai_package_dir=""
local backup_dir=""
local container_id=""
local dist_installed=0
local requires_ai_dist=0
local restore_root=""
local restore_complete=0
local temp_dir=""
cleanup_restore_package_dist() {
if [ -n "$container_id" ]; then
docker_e2e_docker_cmd rm -f "$container_id" >/dev/null 2>&1 || true
fi
# Root and AI artifacts come from one image. Restore both on partial failure
# so the package step cannot combine outputs from different builds.
if [ "$restore_complete" != "1" ]; then
if [ "$dist_installed" = "1" ]; then
rm -rf "$restore_root/dist" >/dev/null 2>&1 || true
fi
if [ -n "$backup_dir" ] && [ -d "$backup_dir" ]; then
if [ ! -e "$restore_root/dist" ] && \
mv "$backup_dir" "$restore_root/dist" >/dev/null 2>&1; then
backup_dir=""
fi
fi
if [ "$ai_dist_installed" = "1" ]; then
rm -rf "$ai_dist_dir" >/dev/null 2>&1 || true
fi
if [ -n "$ai_backup_dir" ] && [ -d "$ai_backup_dir" ]; then
if [ ! -e "$ai_dist_dir" ] && \
mv "$ai_backup_dir" "$ai_dist_dir" >/dev/null 2>&1; then
ai_backup_dir=""
fi
fi
fi
if [ -n "$temp_dir" ]; then
rm -rf "$temp_dir"
fi
if [ "$restore_complete" = "1" ] && [ -n "$backup_dir" ]; then
rm -rf "$backup_dir"
fi
if [ "$restore_complete" = "1" ] && [ -n "$ai_backup_dir" ]; then
rm -rf "$ai_backup_dir"
fi
}
if ! restore_root="$(cd "$ROOT_DIR" && pwd -P)"; then
echo "unable to resolve package restore root: $ROOT_DIR" >&2
return 1
fi
# The trusted workflow owns this static checkout and runs no candidate process
# concurrently. Resolve owner paths once and reuse them through every swap.
if [ -L "$restore_root/packages" ] || [ -L "$restore_root/packages/ai" ]; then
echo "refusing package artifact restore through a symlinked packages path" >&2
return 1
fi
if [ -f "$restore_root/packages/ai/package.json" ]; then
if ! ai_package_dir="$(cd "$restore_root/packages/ai" && pwd -P)"; then
echo "unable to resolve bundled AI package path" >&2
return 1
fi
case "$ai_package_dir/" in
"$restore_root"/*) ;;
*)
echo "refusing bundled AI artifact restore outside the package root" >&2
return 1
;;
esac
ai_dist_dir="$ai_package_dir/dist"
requires_ai_dist=1
fi
echo "==> Reuse package build artifacts from Docker image: $image"
if ! container_id="$(docker_e2e_docker_cmd create "$image")"; then
cleanup_restore_package_dist
return 1
fi
if ! temp_dir="$(mktemp -d "$restore_root/.package-dist.XXXXXX")"; then
cleanup_restore_package_dist
return 1
fi
if ! docker_e2e_docker_cmd cp "${container_id}:/app/dist" "$temp_dir/dist"; then
cleanup_restore_package_dist
return 1
fi
if [ "$requires_ai_dist" = "1" ] && \
! docker_e2e_docker_cmd cp \
"${container_id}:/app/node_modules/@openclaw/ai/dist" \
"$temp_dir/ai-dist"; then
cleanup_restore_package_dist
return 1
fi
if [ -e "$restore_root/dist" ]; then
if ! backup_dir="$(mktemp -d "$restore_root/.dist-backup.XXXXXX")"; then
cleanup_restore_package_dist
return 1
fi
if ! rmdir "$backup_dir"; then
cleanup_restore_package_dist
return 1
fi
if ! mv "$restore_root/dist" "$backup_dir"; then
cleanup_restore_package_dist
return 1
fi
fi
if ! mv "$temp_dir/dist" "$restore_root/dist"; then
cleanup_restore_package_dist
return 1
fi
dist_installed=1
if [ "$requires_ai_dist" = "1" ]; then
if [ -e "$ai_dist_dir" ]; then
if ! ai_backup_dir="$(mktemp -d "$ai_package_dir/.dist-backup.XXXXXX")"; then
cleanup_restore_package_dist
return 1
fi
if ! rmdir "$ai_backup_dir"; then
cleanup_restore_package_dist
return 1
fi
if ! mv "$ai_dist_dir" "$ai_backup_dir"; then
cleanup_restore_package_dist
return 1
fi
fi
if ! mv "$temp_dir/ai-dist" "$ai_dist_dir"; then
cleanup_restore_package_dist
return 1
fi
ai_dist_installed=1
fi
restore_complete=1
cleanup_restore_package_dist
)
docker_e2e_prepare_package_tgz() {
local label="$1"
local package_tgz="${2:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
if [ -n "$package_tgz" ]; then
if [ ! -f "$package_tgz" ]; then
echo "OpenClaw package tarball does not exist: $package_tgz" >&2
return 1
fi
docker_e2e_abs_path "$package_tgz"
return 0
fi
local pack_dir
pack_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-pack.XXXXXX")"
local pack_status=0
# ROOT_DIR can be a frozen candidate; resolve tooling beside this helper.
package_tgz="$(
node "$DOCKER_E2E_PACKAGE_LIB_DIR/../package-openclaw-for-docker.mjs" \
--source-dir "${OPENCLAW_DOCKER_E2E_REPO_ROOT:-$ROOT_DIR}" \
--allow-unreleased-changelog \
--output-dir "$pack_dir" \
--output-name openclaw-current.tgz
)" || pack_status="$?"
if [ "$pack_status" -ne 0 ]; then
rm -rf "$pack_dir"
return "$pack_status"
fi
if [ -z "$package_tgz" ]; then
echo "missing packed OpenClaw tarball" >&2
rm -rf "$pack_dir"
return 1
fi
touch "$pack_dir/.openclaw-docker-e2e-generated-package"
docker_e2e_abs_path "$package_tgz"
}
docker_e2e_prepare_package_context() {
local package_tgz="$1"
local context_dir
context_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-package-context.XXXXXX")"
# BuildKit named contexts must be directories, so expose the tarball as a
# stable filename inside a tiny temporary context.
local copy_status=0
cp "$package_tgz" "$context_dir/openclaw-current.tgz" || copy_status="$?"
if [ "$copy_status" -ne 0 ]; then
rm -rf "$context_dir"
return "$copy_status"
fi
# The root package keeps its published dependency declarations. Carry the
# verified candidate registry into BuildKit so unpublished core packages resolve.
if ! node --input-type=module - \
"$DOCKER_E2E_PACKAGE_LIB_DIR/../prepublish-plugin-registry-artifact.mjs" \
"$context_dir" <<'NODE'
import { createHash } from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { pathToFileURL } from "node:url";
const [, , artifactScript, contextDir] = process.argv;
const registryDir = process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR;
const target = path.join(contextDir, "prepublish-plugin-registry");
fs.mkdirSync(target);
let identity = {};
if (registryDir) {
const { PREPUBLISH_PLUGIN_REGISTRY_MANIFEST, validatePrepublishPluginRegistryArtifact } =
await import(pathToFileURL(artifactScript).href);
const bytes = fs.readFileSync(path.join(registryDir, PREPUBLISH_PLUGIN_REGISTRY_MANIFEST));
const manifest = JSON.parse(bytes);
identity = {
sourceSha: process.env.OPENCLAW_DOCKER_E2E_SELECTED_SHA || manifest.sourceSha,
candidateVersion: process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_CANDIDATE_VERSION || manifest.candidateVersion,
manifestSha256: process.env.OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_MANIFEST_SHA256 || createHash("sha256").update(bytes).digest("hex"),
};
validatePrepublishPluginRegistryArtifact({
artifactDir: registryDir,
expectedSourceSha: identity.sourceSha,
expectedCandidateVersion: identity.candidateVersion,
expectedManifestSha256: identity.manifestSha256,
requiredPackages: [],
});
fs.cpSync(registryDir, target, { recursive: true });
}
fs.writeFileSync(path.join(contextDir, "registry-identity.json"), `${JSON.stringify(identity)}\n`);
NODE
then
rm -rf "$context_dir"
return 1
fi
printf '%s\n' "$context_dir"
}
docker_e2e_package_mount_args() {
local package_tgz="$1"
local target="${2:-/tmp/openclaw-current.tgz}"
DOCKER_E2E_PACKAGE_ARGS=(-v "$package_tgz:$target:ro" -e "OPENCLAW_CURRENT_PACKAGE_TGZ=$target")
if [ -n "${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR:-}" ]; then
source "$DOCKER_E2E_PACKAGE_LIB_DIR/../e2e/lib/prepublish-plugin-registry.sh"
openclaw_prepublish_plugin_registry_configure_docker_args "$OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DIR"
DOCKER_E2E_PACKAGE_ARGS+=(
"${OPENCLAW_PREPUBLISH_PLUGIN_REGISTRY_DOCKER_ARGS[@]}"
)
fi
if [ -n "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-}" ]; then
DOCKER_E2E_PACKAGE_ARGS+=(-e "OPENCLAW_E2E_NPM_INSTALL_TIMEOUT=$OPENCLAW_E2E_NPM_INSTALL_TIMEOUT")
fi
if [ -n "${OPENCLAW_E2E_COMMAND_TIMEOUT:-}" ]; then
DOCKER_E2E_PACKAGE_ARGS+=(-e "OPENCLAW_E2E_COMMAND_TIMEOUT=$OPENCLAW_E2E_COMMAND_TIMEOUT")
fi
}
docker_e2e_cleanup_package_tgz() {
local package_tgz="${1:-}"
[ -n "$package_tgz" ] || return 0
[ "$(basename "$package_tgz")" = "openclaw-current.tgz" ] || return 0
local pack_dir
pack_dir="$(dirname "$package_tgz")"
if [ -f "$pack_dir/.openclaw-docker-e2e-generated-package" ]; then
rm -rf "$pack_dir"
fi
}
docker_e2e_cleanup_package_run() {
docker_e2e_cleanup_package_tgz "${1:-}"
if [ -n "${2:-}" ]; then
rm -f "$2"
fi
}
docker_e2e_cleanup_package_mount_args() {
local expect_volume_path=0
local arg
for arg in "${DOCKER_E2E_PACKAGE_ARGS[@]:-}"; do
if [ "$expect_volume_path" = "1" ]; then
docker_e2e_cleanup_package_tgz "${arg%%:*}"
expect_volume_path=0
continue
fi
if [ "$arg" = "-v" ]; then
expect_volume_path=1
fi
done
}
docker_e2e_cleanup_container_cidfile() {
local cidfile="${1:-}"
[ -n "$cidfile" ] || return 0
if [ -f "$cidfile" ]; then
local container_id
container_id="$(head -n 1 "$cidfile" 2>/dev/null || true)"
if [ -n "$container_id" ]; then
docker_e2e_docker_cmd rm -f "$container_id" >/dev/null 2>&1 || true
fi
rm -f "$cidfile"
fi
}
docker_e2e_print_failed_container_state() {
local cidfile="${1:-}"
[ -f "$cidfile" ] || return 0
local container_id
container_id="$(head -n 1 "$cidfile" 2>/dev/null || true)"
[ -n "$container_id" ] || return 0
local inspect_output=""
local inspect_status=0
inspect_output="$(
docker_e2e_docker_cmd inspect --format 'ExitCode={{.State.ExitCode}}
OOMKilled={{.State.OOMKilled}}
Init={{.HostConfig.Init}}
Error={{printf "%.4096s" .State.Error}}' "$container_id" 2>&1
)" || inspect_status="$?"
if [ "$inspect_status" -ne 0 ]; then
printf 'Docker container state unavailable (inspect exit %s): %.4096s\n' \
"$inspect_status" "$inspect_output" >&2
return 0
fi
echo "Docker container state:" >&2
printf '%.4608s\n' "$inspect_output" >&2
}
docker_e2e_harness_mount_args() {
local harness_root="${DOCKER_E2E_HARNESS_ROOT_DIR:-$ROOT_DIR}"
local windows_helpers="${DOCKER_E2E_WINDOWS_HELPERS_PATH:-$harness_root/scripts/windows-cmd-helpers.mjs}"
DOCKER_E2E_HARNESS_ARGS=(
-v "$harness_root/scripts/e2e:/app/scripts/e2e:ro"
-v "$harness_root/scripts/docker/verify-fs-safe-native.mjs:/app/scripts/docker/verify-fs-safe-native.mjs:ro"
-v "$harness_root/scripts/lib:/app/scripts/lib:ro"
-v "$harness_root/packages/gateway-client/src:/app/packages/gateway-client/src:ro"
-v "$harness_root/packages/normalization-core/package.json:/app/packages/normalization-core/package.json:ro"
-v "$harness_root/packages/normalization-core/src:/app/packages/normalization-core/src:ro"
-v "$harness_root/tsconfig.json:/app/tsconfig.json:ro"
-v "$harness_root/test/e2e/qa-lab:/app/test/e2e/qa-lab:ro"
-v "$harness_root/test/helpers:/app/test/helpers:ro"
-v "$harness_root/scripts/prepublish-plugin-registry-artifact.mjs:/app/scripts/prepublish-plugin-registry-artifact.mjs:ro"
-v "$windows_helpers:/app/scripts/windows-cmd-helpers.mjs:ro"
)
}
docker_e2e_run_with_harness() {
docker_e2e_harness_mount_args
local run_status=0
local cid_dir
local cidfile
local docker_run_pid=""
local harness_stdin_fd=""
local cleanup_done=0
local previous_int_trap
local previous_term_trap
local previous_hup_trap
cid_dir="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-docker-e2e-container.XXXXXX")"
cidfile="$cid_dir/container.cid"
previous_int_trap="$(trap -p INT || true)"
previous_term_trap="$(trap -p TERM || true)"
previous_hup_trap="$(trap -p HUP || true)"
restore_harness_traps() {
if [ -n "$previous_int_trap" ]; then
eval "$previous_int_trap"
else
trap - INT
fi
if [ -n "$previous_term_trap" ]; then
eval "$previous_term_trap"
else
trap - TERM
fi
if [ -n "$previous_hup_trap" ]; then
eval "$previous_hup_trap"
else
trap - HUP
fi
}
docker_e2e_harness_descendant_pids() {
local parent_pid="$1"
local child_pid
for child_pid in $(pgrep -P "$parent_pid" 2>/dev/null || true); do
docker_e2e_harness_descendant_pids "$child_pid"
printf '%s\n' "$child_pid"
done
}
terminate_harness_docker_run() {
[ -n "$docker_run_pid" ] || return 0
kill -0 "$docker_run_pid" 2>/dev/null || return 0
local descendant_pids
descendant_pids="$(docker_e2e_harness_descendant_pids "$docker_run_pid")"
if [ -n "$descendant_pids" ]; then
kill -TERM $descendant_pids 2>/dev/null || true
fi
kill -TERM "$docker_run_pid" 2>/dev/null || true
local grace_seconds="${OPENCLAW_DOCKER_E2E_CONTAINER_TERM_GRACE_SECONDS:-10}"
if ! [[ "$grace_seconds" =~ ^[0-9]+$ ]] || [ "$grace_seconds" -lt 1 ]; then
grace_seconds="10"
else
grace_seconds="$((10#$grace_seconds))"
fi
local wait_attempt
for wait_attempt in $(seq 1 "$((grace_seconds * 10))"); do
if ! kill -0 "$docker_run_pid" 2>/dev/null; then
return 0
fi
/bin/sleep 0.1
done
descendant_pids="$(docker_e2e_harness_descendant_pids "$docker_run_pid")"
if [ -n "$descendant_pids" ]; then
kill -KILL $descendant_pids 2>/dev/null || true
fi
kill -KILL "$docker_run_pid" 2>/dev/null || true
}
cleanup_harness_run() {
local cleanup_status="${1:-$?}"
local exit_after_cleanup="${2:-0}"
if [ "$cleanup_done" = "1" ]; then
if [ "$exit_after_cleanup" = "1" ]; then
exit "$cleanup_status"
fi
return "$cleanup_status"
fi
cleanup_done=1
trap - INT TERM HUP
terminate_harness_docker_run
wait "$docker_run_pid" 2>/dev/null || true
docker_e2e_cleanup_container_cidfile "$cidfile"
rmdir "$cid_dir" 2>/dev/null || true
docker_e2e_cleanup_package_mount_args
if [ -n "$harness_stdin_fd" ]; then
eval "exec ${harness_stdin_fd}<&-"
fi
restore_harness_traps
if [ "$exit_after_cleanup" = "1" ]; then
exit "$cleanup_status"
fi
return "$cleanup_status"
}
trap 'cleanup_harness_run 130 1' INT
trap 'cleanup_harness_run 143 1' TERM
trap 'cleanup_harness_run 129 1' HUP
local candidate_fd
for candidate_fd in 19 18 17 16 15 14 13 12 11 10; do
if ! eval "true <&${candidate_fd}" 2>/dev/null; then
harness_stdin_fd="$candidate_fd"
break
fi
done
if [ -z "$harness_stdin_fd" ]; then
echo "no free file descriptor available for Docker harness stdin" >&2
cleanup_harness_run 1
return 1
fi
eval "exec ${harness_stdin_fd}<&0"
docker_e2e_docker_run_cmd run --cidfile "$cidfile" "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@" <&$harness_stdin_fd &
docker_run_pid="$!"
local had_errexit=0
case "$-" in
*e*)
had_errexit=1
;;
esac
set +e
wait "$docker_run_pid"
run_status="$?"
if [ "$had_errexit" = "1" ]; then
set -e
fi
if [ "$run_status" -ne 0 ]; then
docker_e2e_print_failed_container_state "$cidfile"
fi
cleanup_harness_run 0
return "$run_status"
}
docker_e2e_run_detached_with_harness() {
docker_e2e_harness_mount_args
docker_e2e_docker_cmd run -d "${DOCKER_E2E_HARNESS_ARGS[@]}" "$@"
}
docker_e2e_run_logged_with_harness() {
local label="$1"
shift
run_logged "$label" docker_e2e_run_with_harness "$@"
}
docker_e2e_run_logged_print_with_harness() {
local label="$1"
shift
local heartbeat_seconds
heartbeat_seconds="$(docker_e2e_read_positive_int_env OPENCLAW_DOCKER_E2E_LOG_HEARTBEAT_SECONDS 30)" || return $?
run_logged_print_heartbeat \
"$label" \
"$heartbeat_seconds" \
docker_e2e_run_with_harness \
"$@"
}