mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-07 02:22:46 +00:00
313 lines
12 KiB
Bash
Executable File
313 lines
12 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
|
source "$ROOT_DIR/scripts/lib/docker-e2e-image.sh"
|
|
|
|
IMAGE_NAME="$(docker_e2e_resolve_image "openclaw-docker-e2e-functional:local")"
|
|
PACKAGE_TGZ="$(docker_e2e_prepare_package_tgz compose-setup "${OPENCLAW_CURRENT_PACKAGE_TGZ:-}")"
|
|
IDENTITY_PATH="${OPENCLAW_DOCKER_ARTIFACT_IDENTITY_PATH:-$ROOT_DIR/.artifacts/docker-tests/compose-setup-identities.json}"
|
|
PROJECT_DIR="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-compose-proof.XXXXXX")"
|
|
PROJECT_NAME="openclaw-compose-proof-$$"
|
|
CLI_NAME="$PROJECT_NAME-cli-proof"
|
|
TOKEN="compose-proof-$$-$(date +%s)"
|
|
WRONG_TOKEN="$TOKEN-wrong"
|
|
HEALTH_OVERRIDE_PATH="$PROJECT_DIR/compose-health-override.yaml"
|
|
COMPOSE=(
|
|
docker compose
|
|
--project-name "$PROJECT_NAME"
|
|
--project-directory "$PROJECT_DIR"
|
|
-f "$ROOT_DIR/docker-compose.yml"
|
|
-f "$HEALTH_OVERRIDE_PATH"
|
|
)
|
|
|
|
cleanup() {
|
|
docker_e2e_docker_cmd rm -f "$CLI_NAME" >/dev/null 2>&1 || true
|
|
"${COMPOSE[@]}" down --remove-orphans --volumes >/dev/null 2>&1 || true
|
|
docker_e2e_cleanup_package_tgz "$PACKAGE_TGZ"
|
|
docker_e2e_docker_cmd run --rm --user 0:0 \
|
|
-v "$PROJECT_DIR:/target" \
|
|
"$IMAGE_NAME" \
|
|
sh -c 'rm -rf /target/* /target/.[!.]* /target/..?*' >/dev/null 2>&1 || true
|
|
rm -rf "$PROJECT_DIR"
|
|
}
|
|
trap cleanup EXIT
|
|
|
|
assert_gateway_health_json() {
|
|
local label="$1"
|
|
local health_path="$2"
|
|
node - "$label" "$health_path" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const label = process.argv[2];
|
|
const healthPath = process.argv[3];
|
|
const health = JSON.parse(fs.readFileSync(healthPath, "utf8"));
|
|
if (
|
|
health?.ok !== true ||
|
|
!Number.isFinite(health.ts) ||
|
|
!Number.isFinite(health.durationMs) ||
|
|
!health.channels ||
|
|
typeof health.channels !== "object" ||
|
|
Array.isArray(health.channels)
|
|
) {
|
|
throw new Error(`${label} gateway health JSON is incomplete`);
|
|
}
|
|
NODE
|
|
echo "$label accepted the configured token and returned a complete health envelope."
|
|
}
|
|
|
|
assert_dockerfile_healthcheck() {
|
|
node - "$ROOT_DIR/Dockerfile" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const dockerfilePath = process.argv[2];
|
|
const dockerfile = fs.readFileSync(dockerfilePath, "utf8").replace(/\\\r?\n[ \t]*/g, " ");
|
|
if (!/HEALTHCHECK\b[^\n]*CMD \["node", "dist\/docker-healthcheck\.js"\]/u.test(dockerfile)) {
|
|
throw new Error("Dockerfile does not install the built Gateway healthcheck");
|
|
}
|
|
NODE
|
|
echo "Dockerfile healthcheck definition uses dist/docker-healthcheck.js."
|
|
}
|
|
|
|
assert_effective_healthcheck() {
|
|
local label="$1"
|
|
local kind="$2"
|
|
local inspect_path="$3"
|
|
node - "$label" "$kind" "$inspect_path" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const label = process.argv[2];
|
|
const kind = process.argv[3];
|
|
const inspectPath = process.argv[4];
|
|
const payload = JSON.parse(fs.readFileSync(inspectPath, "utf8"));
|
|
const actual =
|
|
kind === "compose"
|
|
? payload?.services?.["openclaw-gateway"]?.healthcheck?.test
|
|
: payload?.[0]?.Config?.Healthcheck?.Test;
|
|
const expected = ["CMD", "node", "dist/docker-healthcheck.js"];
|
|
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
|
throw new Error(`${label} healthcheck mismatch: ${JSON.stringify(actual)}`);
|
|
}
|
|
NODE
|
|
echo "$label uses dist/docker-healthcheck.js."
|
|
}
|
|
|
|
wait_for_gateway_health() {
|
|
local expected="$1"
|
|
local attempts="$2"
|
|
local health=""
|
|
for _ in $(seq 1 "$attempts"); do
|
|
health="$(docker inspect --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}' "$GATEWAY_ID")"
|
|
if [ "$health" = "$expected" ]; then
|
|
return 0
|
|
fi
|
|
if [ "$health" = "exited" ] || [ "$health" = "dead" ]; then
|
|
break
|
|
fi
|
|
sleep 1
|
|
done
|
|
echo "Gateway health did not reach $expected (last state: $health)" >&2
|
|
docker inspect --format '{{json .State.Health}}' "$GATEWAY_ID" >&2 || true
|
|
"${COMPOSE[@]}" logs --no-color openclaw-gateway >&2 || true
|
|
return 1
|
|
}
|
|
|
|
assert_probe_endpoints() {
|
|
local label="$1"
|
|
local output_path="$2"
|
|
"${COMPOSE[@]}" exec -T openclaw-gateway node - "$label" >"$output_path" <<'NODE'
|
|
const label = process.argv[2];
|
|
const readJson = async (path) => {
|
|
const response = await fetch(`http://127.0.0.1:18789${path}`);
|
|
const body = await response.json();
|
|
return { body, status: response.status };
|
|
};
|
|
const health = await readJson("/healthz");
|
|
const ready = await readJson("/readyz");
|
|
if (
|
|
health.status !== 200 ||
|
|
health.body?.ok !== true ||
|
|
health.body?.status !== "live"
|
|
) {
|
|
throw new Error(`${label} /healthz did not report live: ${JSON.stringify(health)}`);
|
|
}
|
|
if (
|
|
ready.status !== 200 ||
|
|
ready.body?.ready !== true ||
|
|
!Array.isArray(ready.body?.failing) ||
|
|
ready.body.failing.length !== 0
|
|
) {
|
|
throw new Error(`${label} /readyz did not report ready: ${JSON.stringify(ready)}`);
|
|
}
|
|
console.log(JSON.stringify({ healthz: health, label, readyz: ready }));
|
|
NODE
|
|
echo "$label: /healthz is live and /readyz is ready."
|
|
}
|
|
|
|
assert_auth_rejected() {
|
|
local label="$1"
|
|
local stdout_path="$2"
|
|
local stderr_path="$3"
|
|
shift 3
|
|
local exit_code
|
|
set +e
|
|
"$@" >"$stdout_path" 2>"$stderr_path"
|
|
exit_code=$?
|
|
set -e
|
|
if [ "$exit_code" -eq 0 ]; then
|
|
echo "$label accepted an invalid Gateway token" >&2
|
|
return 1
|
|
fi
|
|
node - "$label" "$stdout_path" "$stderr_path" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const label = process.argv[2];
|
|
const output = [process.argv[3], process.argv[4]]
|
|
.map((path) => fs.readFileSync(path, "utf8"))
|
|
.join("\n");
|
|
if (!/(unauthorized|token mismatch|authentication)/iu.test(output)) {
|
|
throw new Error(`${label} failed without an authentication diagnostic`);
|
|
}
|
|
NODE
|
|
echo "$label rejected an invalid Gateway token (exit $exit_code)."
|
|
}
|
|
|
|
assert_health_state() {
|
|
local label="$1"
|
|
local expected="$2"
|
|
local health_path="$3"
|
|
node - "$label" "$expected" "$health_path" <<'NODE'
|
|
const fs = require("node:fs");
|
|
const label = process.argv[2];
|
|
const expected = process.argv[3];
|
|
const healthPath = process.argv[4];
|
|
const health = JSON.parse(fs.readFileSync(healthPath, "utf8"));
|
|
if (health?.Status !== expected || !Array.isArray(health.Log) || health.Log.length === 0) {
|
|
throw new Error(`${label} Docker health state is incomplete: ${JSON.stringify(health)}`);
|
|
}
|
|
const expectedExit = expected === "unhealthy" ? (code) => code !== 0 : (code) => code === 0;
|
|
if (!health.Log.some((entry) => expectedExit(Number(entry?.ExitCode)))) {
|
|
throw new Error(`${label} Docker health log lacks the expected exit status`);
|
|
}
|
|
console.log(`${label}: ${JSON.stringify({ log: health.Log.slice(-3), status: health.Status })}`);
|
|
NODE
|
|
}
|
|
|
|
mkdir -p "$PROJECT_DIR/config/workspace" "$PROJECT_DIR/auth-profile"
|
|
chmod -R 0777 "$PROJECT_DIR/config" "$PROJECT_DIR/auth-profile"
|
|
cat >"$PROJECT_DIR/config/openclaw.json" <<EOF
|
|
{
|
|
"gateway": {
|
|
"mode": "local",
|
|
"auth": { "mode": "token", "token": "$TOKEN" },
|
|
"controlUi": { "enabled": false }
|
|
}
|
|
}
|
|
EOF
|
|
cat >"$HEALTH_OVERRIDE_PATH" <<'EOF'
|
|
services:
|
|
openclaw-gateway:
|
|
healthcheck:
|
|
interval: 1s
|
|
timeout: 5s
|
|
retries: 3
|
|
start_period: 5s
|
|
EOF
|
|
|
|
export OPENCLAW_IMAGE="$IMAGE_NAME"
|
|
export OPENCLAW_CONFIG_DIR="$PROJECT_DIR/config"
|
|
export OPENCLAW_WORKSPACE_DIR="$PROJECT_DIR/config/workspace"
|
|
export OPENCLAW_AUTH_PROFILE_SECRET_DIR="$PROJECT_DIR/auth-profile"
|
|
export OPENCLAW_GATEWAY_TOKEN="$TOKEN"
|
|
export OPENCLAW_GATEWAY_PORT=0
|
|
export OPENCLAW_BRIDGE_PORT=0
|
|
export OPENCLAW_MSTEAMS_PORT=0
|
|
export OPENCLAW_DISABLE_BONJOUR=1
|
|
export OPENCLAW_CURRENT_PACKAGE_TGZ="$PACKAGE_TGZ"
|
|
|
|
docker_e2e_build_or_reuse "$IMAGE_NAME" compose-setup "$ROOT_DIR/scripts/e2e/Dockerfile" "$ROOT_DIR" functional
|
|
|
|
assert_dockerfile_healthcheck
|
|
"${COMPOSE[@]}" config --format json >"$PROJECT_DIR/compose-config.json"
|
|
assert_effective_healthcheck "resolved Compose service" compose "$PROJECT_DIR/compose-config.json"
|
|
|
|
echo "Launching documented Docker Compose gateway topology..."
|
|
"${COMPOSE[@]}" up -d --no-build openclaw-gateway
|
|
GATEWAY_ID="$("${COMPOSE[@]}" ps -q openclaw-gateway)"
|
|
if [ -z "$GATEWAY_ID" ]; then
|
|
echo "Compose did not create openclaw-gateway" >&2
|
|
exit 1
|
|
fi
|
|
docker inspect "$GATEWAY_ID" >"$PROJECT_DIR/gateway-container.json"
|
|
assert_effective_healthcheck "running Gateway container" container "$PROJECT_DIR/gateway-container.json"
|
|
|
|
wait_for_gateway_health healthy 180
|
|
docker inspect --format '{{json .State.Health}}' "$GATEWAY_ID" >"$PROJECT_DIR/health-initial.json"
|
|
assert_health_state "initial healthy state" healthy "$PROJECT_DIR/health-initial.json"
|
|
assert_probe_endpoints "initial probes" "$PROJECT_DIR/probes-initial.json"
|
|
|
|
"${COMPOSE[@]}" exec -T openclaw-gateway sh -lc 'node dist/index.js gateway health --token "$OPENCLAW_GATEWAY_TOKEN"'
|
|
assert_auth_rejected \
|
|
"gateway service" \
|
|
"$PROJECT_DIR/gateway-wrong-token.json" \
|
|
"$PROJECT_DIR/gateway-wrong-token.err" \
|
|
"${COMPOSE[@]}" exec -T openclaw-gateway \
|
|
node dist/index.js gateway health --token "$WRONG_TOKEN" --json
|
|
"${COMPOSE[@]}" exec -T openclaw-gateway node dist/index.js gateway health --token "$TOKEN" --json >"$PROJECT_DIR/gateway-health.json"
|
|
assert_gateway_health_json "gateway service" "$PROJECT_DIR/gateway-health.json"
|
|
assert_auth_rejected \
|
|
"CLI sidecar" \
|
|
"$PROJECT_DIR/cli-wrong-token.json" \
|
|
"$PROJECT_DIR/cli-wrong-token.err" \
|
|
"${COMPOSE[@]}" run -T --rm --no-deps \
|
|
openclaw-cli gateway health --token "$WRONG_TOKEN" --json
|
|
"${COMPOSE[@]}" run -T --no-deps --name "$CLI_NAME" openclaw-cli gateway health --token "$TOKEN" --json >"$PROJECT_DIR/cli-health.json"
|
|
assert_gateway_health_json "CLI sidecar" "$PROJECT_DIR/cli-health.json"
|
|
|
|
echo "Forcing the configured Docker healthcheck to fail..."
|
|
docker exec --user 0:0 "$GATEWAY_ID" \
|
|
sh -c 'test -f /app/dist/docker-healthcheck.js && mv /app/dist/docker-healthcheck.js /app/dist/docker-healthcheck.js.c3-disabled'
|
|
wait_for_gateway_health unhealthy 30
|
|
docker inspect --format '{{json .State.Health}}' "$GATEWAY_ID" >"$PROJECT_DIR/health-unhealthy.json"
|
|
assert_health_state "forced unhealthy state" unhealthy "$PROJECT_DIR/health-unhealthy.json"
|
|
docker exec --user 0:0 "$GATEWAY_ID" \
|
|
mv /app/dist/docker-healthcheck.js.c3-disabled /app/dist/docker-healthcheck.js
|
|
wait_for_gateway_health healthy 60
|
|
docker inspect --format '{{json .State.Health}}' "$GATEWAY_ID" >"$PROJECT_DIR/health-recovered.json"
|
|
assert_health_state "recovered healthy state" healthy "$PROJECT_DIR/health-recovered.json"
|
|
assert_probe_endpoints "recovered probes" "$PROJECT_DIR/probes-recovered.json"
|
|
|
|
"${COMPOSE[@]}" logs --no-color openclaw-gateway >"$PROJECT_DIR/gateway-compose.log"
|
|
if [ ! -s "$PROJECT_DIR/gateway-compose.log" ]; then
|
|
echo "Compose gateway logs were empty" >&2
|
|
exit 1
|
|
fi
|
|
echo "Compose gateway log tail:"
|
|
tail -n 40 "$PROJECT_DIR/gateway-compose.log"
|
|
|
|
GATEWAY_VERSION="$("${COMPOSE[@]}" exec -T openclaw-gateway node -p "require('./package.json').version")"
|
|
|
|
node --import tsx "$ROOT_DIR/scripts/e2e/lib/docker-artifact-proof/write-identities.ts" \
|
|
--scenario compose-setup \
|
|
--output "$IDENTITY_PATH" \
|
|
--image "$IMAGE_NAME" \
|
|
--package "$PACKAGE_TGZ" \
|
|
--container "gateway=$GATEWAY_ID" \
|
|
--container "cli=$CLI_NAME" \
|
|
--detail "gateway:openclawVersion=$GATEWAY_VERSION" \
|
|
--detail "gateway:health=healthy" \
|
|
--detail "gateway:dockerfileHealthcheckDefinition=passed" \
|
|
--detail "gateway:composeHealthcheckResolved=passed" \
|
|
--detail "gateway:runtimeHealthcheckEffective=passed" \
|
|
--detail "gateway:healthz=live" \
|
|
--detail "gateway:readyz=ready" \
|
|
--detail "gateway:wrongTokenRejected=passed" \
|
|
--detail "gateway:correctTokenAccepted=passed" \
|
|
--detail "gateway:dockerUnhealthy=observed" \
|
|
--detail "gateway:dockerRecovery=passed" \
|
|
--detail "gateway:composeLogs=observed" \
|
|
--detail "gateway:healthStateDiagnostics=observed" \
|
|
--detail "gateway:documentedHealthCommand=passed" \
|
|
--detail "gateway:healthJsonEnvelope=passed" \
|
|
--detail "cli:wrongTokenRejected=passed" \
|
|
--detail "cli:correctTokenAccepted=passed" \
|
|
--detail "cli:healthJsonEnvelope=passed"
|
|
|
|
echo "Docker Compose setup proof passed."
|