openclaw/scripts/e2e/parallels-linux-smoke.sh

674 lines
18 KiB
Bash

#!/usr/bin/env bash
set -euo pipefail
VM_NAME="Ubuntu 24.04.3 ARM64"
SNAPSHOT_HINT="fresh"
MODE="both"
OPENAI_API_KEY_ENV="OPENAI_API_KEY"
INSTALL_URL="https://openclaw.ai/install.sh"
HOST_PORT="18427"
HOST_PORT_EXPLICIT=0
HOST_IP=""
LATEST_VERSION=""
INSTALL_VERSION=""
TARGET_PACKAGE_SPEC=""
JSON_OUTPUT=0
KEEP_SERVER=0
MAIN_TGZ_DIR="$(mktemp -d)"
MAIN_TGZ_PATH=""
SERVER_PID=""
RUN_DIR="$(mktemp -d /tmp/openclaw-parallels-linux.XXXXXX)"
BUILD_LOCK_DIR="${TMPDIR:-/tmp}/openclaw-parallels-build.lock"
TIMEOUT_SNAPSHOT_S=180
TIMEOUT_BOOTSTRAP_S=600
TIMEOUT_INSTALL_S=1200
TIMEOUT_VERIFY_S=90
TIMEOUT_ONBOARD_S=180
TIMEOUT_AGENT_S=180
FRESH_MAIN_STATUS="skip"
FRESH_MAIN_VERSION="skip"
FRESH_GATEWAY_STATUS="skip"
FRESH_AGENT_STATUS="skip"
UPGRADE_STATUS="skip"
LATEST_INSTALLED_VERSION="skip"
UPGRADE_MAIN_VERSION="skip"
UPGRADE_GATEWAY_STATUS="skip"
UPGRADE_AGENT_STATUS="skip"
DAEMON_STATUS="systemd-user-unavailable"
say() {
printf '==> %s\n' "$*"
}
artifact_label() {
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
printf 'target package tgz'
return
fi
printf 'current main tgz'
}
warn() {
printf 'warn: %s\n' "$*" >&2
}
die() {
printf 'error: %s\n' "$*" >&2
exit 1
}
cleanup() {
if [[ -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
fi
rm -rf "$MAIN_TGZ_DIR"
}
trap cleanup EXIT
usage() {
cat <<'EOF'
Usage: bash scripts/e2e/parallels-linux-smoke.sh [options]
Options:
--vm <name> Parallels VM name. Default: "Ubuntu 24.04.3 ARM64"
--snapshot-hint <name> Snapshot name substring/fuzzy match. Default: "fresh"
--mode <fresh|upgrade|both>
--openai-api-key-env <var> Host env var name for OpenAI API key. Default: OPENAI_API_KEY
--install-url <url> Installer URL for latest release. Default: https://openclaw.ai/install.sh
--host-port <port> Host HTTP port for current-main tgz. Default: 18427
--host-ip <ip> Override Parallels host IP.
--latest-version <ver> Override npm latest version lookup.
--install-version <ver> Pin site-installer version/dist-tag for the baseline lane.
--target-package-spec <npm-spec>
Install this npm package tarball instead of packing current main.
Example: openclaw@2026.3.13-beta.1
--keep-server Leave temp host HTTP server running.
--json Print machine-readable JSON summary.
-h, --help Show help.
EOF
}
while [[ $# -gt 0 ]]; do
case "$1" in
--vm)
VM_NAME="$2"
shift 2
;;
--snapshot-hint)
SNAPSHOT_HINT="$2"
shift 2
;;
--mode)
MODE="$2"
shift 2
;;
--openai-api-key-env)
OPENAI_API_KEY_ENV="$2"
shift 2
;;
--install-url)
INSTALL_URL="$2"
shift 2
;;
--host-port)
HOST_PORT="$2"
HOST_PORT_EXPLICIT=1
shift 2
;;
--host-ip)
HOST_IP="$2"
shift 2
;;
--latest-version)
LATEST_VERSION="$2"
shift 2
;;
--install-version)
INSTALL_VERSION="$2"
shift 2
;;
--target-package-spec)
TARGET_PACKAGE_SPEC="$2"
shift 2
;;
--keep-server)
KEEP_SERVER=1
shift
;;
--json)
JSON_OUTPUT=1
shift
;;
-h|--help)
usage
exit 0
;;
*)
die "unknown arg: $1"
;;
esac
done
case "$MODE" in
fresh|upgrade|both) ;;
*)
die "invalid --mode: $MODE"
;;
esac
OPENAI_API_KEY_VALUE="${!OPENAI_API_KEY_ENV:-}"
[[ -n "$OPENAI_API_KEY_VALUE" ]] || die "$OPENAI_API_KEY_ENV is required"
resolve_snapshot_id() {
local json hint
json="$(prlctl snapshot-list "$VM_NAME" --json)"
hint="$SNAPSHOT_HINT"
SNAPSHOT_JSON="$json" SNAPSHOT_HINT="$hint" python3 - <<'PY'
import difflib
import json
import os
import sys
payload = json.loads(os.environ["SNAPSHOT_JSON"])
hint = os.environ["SNAPSHOT_HINT"].strip().lower()
best_id = None
best_score = -1.0
for snapshot_id, meta in payload.items():
name = str(meta.get("name", "")).strip()
lowered = name.lower()
score = 0.0
if lowered == hint:
score = 10.0
elif hint and hint in lowered:
score = 5.0 + len(hint) / max(len(lowered), 1)
else:
score = difflib.SequenceMatcher(None, hint, lowered).ratio()
if score > best_score:
best_score = score
best_id = snapshot_id
if not best_id:
sys.exit("no snapshot matched")
print(best_id)
PY
}
resolve_host_ip() {
if [[ -n "$HOST_IP" ]]; then
printf '%s\n' "$HOST_IP"
return
fi
local detected
detected="$(ifconfig | awk '/inet 10\.211\./ { print $2; exit }')"
[[ -n "$detected" ]] || die "failed to detect Parallels host IP; pass --host-ip"
printf '%s\n' "$detected"
}
is_host_port_free() {
local port="$1"
python3 - "$port" <<'PY'
import socket
import sys
sock = socket.socket()
try:
sock.bind(("0.0.0.0", int(sys.argv[1])))
except OSError:
raise SystemExit(1)
finally:
sock.close()
PY
}
allocate_host_port() {
python3 - <<'PY'
import socket
sock = socket.socket()
sock.bind(("0.0.0.0", 0))
print(sock.getsockname()[1])
sock.close()
PY
}
resolve_host_port() {
if is_host_port_free "$HOST_PORT"; then
printf '%s\n' "$HOST_PORT"
return
fi
if [[ "$HOST_PORT_EXPLICIT" -eq 1 ]]; then
die "host port $HOST_PORT already in use"
fi
HOST_PORT="$(allocate_host_port)"
warn "host port 18427 busy; using $HOST_PORT"
printf '%s\n' "$HOST_PORT"
}
guest_exec() {
prlctl exec "$VM_NAME" "$@"
}
restore_snapshot() {
local snapshot_id="$1"
say "Restore snapshot $SNAPSHOT_HINT ($snapshot_id)"
prlctl snapshot-switch "$VM_NAME" --id "$snapshot_id" >/dev/null
}
bootstrap_guest() {
guest_exec apt-get -o Acquire::Check-Date=false update
guest_exec apt-get install -y curl ca-certificates
}
resolve_latest_version() {
if [[ -n "$LATEST_VERSION" ]]; then
printf '%s\n' "$LATEST_VERSION"
return
fi
npm view openclaw version --userconfig "$(mktemp)"
}
current_build_commit() {
python3 - <<'PY'
import json
import pathlib
path = pathlib.Path("dist/build-info.json")
if not path.exists():
print("")
else:
print(json.loads(path.read_text()).get("commit", ""))
PY
}
acquire_build_lock() {
local owner_pid=""
while ! mkdir "$BUILD_LOCK_DIR" 2>/dev/null; do
if [[ -f "$BUILD_LOCK_DIR/pid" ]]; then
owner_pid="$(cat "$BUILD_LOCK_DIR/pid" 2>/dev/null || true)"
if [[ -n "$owner_pid" ]] && ! kill -0 "$owner_pid" >/dev/null 2>&1; then
warn "Removing stale Parallels build lock"
rm -rf "$BUILD_LOCK_DIR"
continue
fi
fi
sleep 1
done
printf '%s\n' "$$" >"$BUILD_LOCK_DIR/pid"
}
release_build_lock() {
if [[ -d "$BUILD_LOCK_DIR" ]]; then
rm -rf "$BUILD_LOCK_DIR"
fi
}
ensure_current_build() {
local head build_commit
acquire_build_lock
head="$(git rev-parse HEAD)"
build_commit="$(current_build_commit)"
if [[ "$build_commit" == "$head" ]]; then
release_build_lock
return
fi
say "Build dist for current head"
pnpm build
build_commit="$(current_build_commit)"
release_build_lock
[[ "$build_commit" == "$head" ]] || die "dist/build-info.json still does not match HEAD after build"
}
extract_package_version_from_tgz() {
tar -xOf "$1" package/package.json | python3 -c 'import json, sys; print(json.load(sys.stdin)["version"])'
}
pack_main_tgz() {
local short_head pkg
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
say "Pack target package tgz: $TARGET_PACKAGE_SPEC"
pkg="$(
npm pack "$TARGET_PACKAGE_SPEC" --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
)"
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/$(basename "$pkg")"
TARGET_EXPECT_VERSION="$(extract_package_version_from_tgz "$MAIN_TGZ_PATH")"
say "Packed $MAIN_TGZ_PATH"
say "Target package version: $TARGET_EXPECT_VERSION"
return
fi
say "Pack current main tgz"
ensure_current_build
short_head="$(git rev-parse --short HEAD)"
pkg="$(
npm pack --ignore-scripts --json --pack-destination "$MAIN_TGZ_DIR" \
| python3 -c 'import json, sys; data = json.load(sys.stdin); print(data[-1]["filename"])'
)"
MAIN_TGZ_PATH="$MAIN_TGZ_DIR/openclaw-main-$short_head.tgz"
cp "$MAIN_TGZ_DIR/$pkg" "$MAIN_TGZ_PATH"
say "Packed $MAIN_TGZ_PATH"
tar -xOf "$MAIN_TGZ_PATH" package/dist/build-info.json
}
verify_target_version() {
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
verify_version_contains "$TARGET_EXPECT_VERSION"
return
fi
verify_version_contains "$(git rev-parse --short=7 HEAD)"
}
start_server() {
local host_ip="$1"
local artifact probe_url attempt
artifact="$(basename "$MAIN_TGZ_PATH")"
attempt=0
while :; do
attempt=$((attempt + 1))
say "Serve $(artifact_label) on $host_ip:$HOST_PORT"
(
cd "$MAIN_TGZ_DIR"
exec python3 -m http.server "$HOST_PORT" --bind 0.0.0.0
) >/tmp/openclaw-parallels-linux-http.log 2>&1 &
SERVER_PID=$!
sleep 1
probe_url="http://127.0.0.1:$HOST_PORT/$artifact"
if kill -0 "$SERVER_PID" >/dev/null 2>&1 && curl -fsSI "$probe_url" >/dev/null 2>&1; then
return 0
fi
kill "$SERVER_PID" >/dev/null 2>&1 || true
wait "$SERVER_PID" >/dev/null 2>&1 || true
SERVER_PID=""
if [[ "$HOST_PORT_EXPLICIT" -eq 1 || $attempt -ge 3 ]]; then
die "failed to start reachable host HTTP server on port $HOST_PORT"
fi
HOST_PORT="$(allocate_host_port)"
warn "retrying host HTTP server on port $HOST_PORT"
done
}
install_latest_release() {
local version_args=()
if [[ -n "$INSTALL_VERSION" ]]; then
version_args=(--version "$INSTALL_VERSION")
fi
guest_exec curl -fsSL "$INSTALL_URL" -o /tmp/openclaw-install.sh
guest_exec /usr/bin/env OPENCLAW_NO_ONBOARD=1 bash /tmp/openclaw-install.sh "${version_args[@]}" --no-onboard
guest_exec openclaw --version
}
install_main_tgz() {
local host_ip="$1"
local temp_name="$2"
local tgz_url="http://$host_ip:$HOST_PORT/$(basename "$MAIN_TGZ_PATH")"
guest_exec curl -fsSL "$tgz_url" -o "/tmp/$temp_name"
guest_exec npm install -g "/tmp/$temp_name" --no-fund --no-audit
guest_exec openclaw --version
}
verify_version_contains() {
local needle="$1"
local version
version="$(guest_exec openclaw --version)"
printf '%s\n' "$version"
case "$version" in
*"$needle"*) ;;
*)
echo "version mismatch: expected substring $needle" >&2
return 1
;;
esac
}
run_ref_onboard() {
guest_exec /usr/bin/env "OPENAI_API_KEY=$OPENAI_API_KEY_VALUE" openclaw onboard \
--non-interactive \
--mode local \
--auth-choice openai-api-key \
--secret-input-mode ref \
--gateway-port 18789 \
--gateway-bind loopback \
--skip-skills \
--skip-health \
--accept-risk \
--json
}
verify_local_turn() {
guest_exec /usr/bin/env "OPENAI_API_KEY=$OPENAI_API_KEY_VALUE" openclaw agent \
--local \
--agent main \
--message ping \
--json
}
phase_log_path() {
printf '%s/%s.log\n' "$RUN_DIR" "$1"
}
extract_last_version() {
local log_path="$1"
python3 - "$log_path" <<'PY'
import pathlib
import re
import sys
text = pathlib.Path(sys.argv[1]).read_text(errors="replace")
matches = re.findall(r"OpenClaw [^\r\n]+ \([0-9a-f]{7,}\)", text)
print(matches[-1] if matches else "")
PY
}
show_log_excerpt() {
local log_path="$1"
warn "log tail: $log_path"
tail -n 80 "$log_path" >&2 || true
}
phase_run() {
local phase_id="$1"
local timeout_s="$2"
shift 2
local log_path pid start rc timed_out
log_path="$(phase_log_path "$phase_id")"
say "$phase_id"
start=$SECONDS
timed_out=0
(
"$@"
) >"$log_path" 2>&1 &
pid=$!
while kill -0 "$pid" >/dev/null 2>&1; do
if (( SECONDS - start >= timeout_s )); then
timed_out=1
kill "$pid" >/dev/null 2>&1 || true
sleep 2
kill -9 "$pid" >/dev/null 2>&1 || true
break
fi
sleep 1
done
set +e
wait "$pid"
rc=$?
set -e
if (( timed_out )); then
warn "$phase_id timed out after ${timeout_s}s"
printf 'timeout after %ss\n' "$timeout_s" >>"$log_path"
show_log_excerpt "$log_path"
return 124
fi
if [[ $rc -ne 0 ]]; then
warn "$phase_id failed (rc=$rc)"
show_log_excerpt "$log_path"
return "$rc"
fi
return 0
}
write_summary_json() {
local summary_path="$RUN_DIR/summary.json"
python3 - "$summary_path" <<'PY'
import json
import os
import sys
summary = {
"vm": os.environ["SUMMARY_VM"],
"snapshotHint": os.environ["SUMMARY_SNAPSHOT_HINT"],
"snapshotId": os.environ["SUMMARY_SNAPSHOT_ID"],
"mode": os.environ["SUMMARY_MODE"],
"latestVersion": os.environ["SUMMARY_LATEST_VERSION"],
"installVersion": os.environ["SUMMARY_INSTALL_VERSION"],
"targetPackageSpec": os.environ["SUMMARY_TARGET_PACKAGE_SPEC"],
"currentHead": os.environ["SUMMARY_CURRENT_HEAD"],
"runDir": os.environ["SUMMARY_RUN_DIR"],
"daemon": os.environ["SUMMARY_DAEMON_STATUS"],
"freshMain": {
"status": os.environ["SUMMARY_FRESH_MAIN_STATUS"],
"version": os.environ["SUMMARY_FRESH_MAIN_VERSION"],
"gateway": os.environ["SUMMARY_FRESH_GATEWAY_STATUS"],
"agent": os.environ["SUMMARY_FRESH_AGENT_STATUS"],
},
"upgrade": {
"status": os.environ["SUMMARY_UPGRADE_STATUS"],
"latestVersionInstalled": os.environ["SUMMARY_LATEST_INSTALLED_VERSION"],
"mainVersion": os.environ["SUMMARY_UPGRADE_MAIN_VERSION"],
"gateway": os.environ["SUMMARY_UPGRADE_GATEWAY_STATUS"],
"agent": os.environ["SUMMARY_UPGRADE_AGENT_STATUS"],
},
}
with open(sys.argv[1], "w", encoding="utf-8") as handle:
json.dump(summary, handle, indent=2, sort_keys=True)
print(sys.argv[1])
PY
}
run_fresh_main_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "fresh.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "fresh.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest
phase_run "fresh.install-latest-bootstrap" "$TIMEOUT_INSTALL_S" install_latest_release
phase_run "fresh.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-fresh.tgz"
FRESH_MAIN_VERSION="$(extract_last_version "$(phase_log_path fresh.install-main)")"
phase_run "fresh.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
phase_run "fresh.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
FRESH_GATEWAY_STATUS="skipped-no-detached-linux-gateway"
phase_run "fresh.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn
FRESH_AGENT_STATUS="pass"
}
run_upgrade_lane() {
local snapshot_id="$1"
local host_ip="$2"
phase_run "upgrade.restore-snapshot" "$TIMEOUT_SNAPSHOT_S" restore_snapshot "$snapshot_id"
phase_run "upgrade.bootstrap-guest" "$TIMEOUT_BOOTSTRAP_S" bootstrap_guest
phase_run "upgrade.install-latest" "$TIMEOUT_INSTALL_S" install_latest_release
LATEST_INSTALLED_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-latest)")"
phase_run "upgrade.verify-latest-version" "$TIMEOUT_VERIFY_S" verify_version_contains "$LATEST_VERSION"
phase_run "upgrade.install-main" "$TIMEOUT_INSTALL_S" install_main_tgz "$host_ip" "openclaw-main-upgrade.tgz"
UPGRADE_MAIN_VERSION="$(extract_last_version "$(phase_log_path upgrade.install-main)")"
phase_run "upgrade.verify-main-version" "$TIMEOUT_VERIFY_S" verify_target_version
phase_run "upgrade.onboard-ref" "$TIMEOUT_ONBOARD_S" run_ref_onboard
UPGRADE_GATEWAY_STATUS="skipped-no-detached-linux-gateway"
phase_run "upgrade.first-local-agent-turn" "$TIMEOUT_AGENT_S" verify_local_turn
UPGRADE_AGENT_STATUS="pass"
}
SNAPSHOT_ID="$(resolve_snapshot_id)"
LATEST_VERSION="$(resolve_latest_version)"
HOST_IP="$(resolve_host_ip)"
HOST_PORT="$(resolve_host_port)"
say "VM: $VM_NAME"
say "Snapshot hint: $SNAPSHOT_HINT"
say "Latest npm version: $LATEST_VERSION"
say "Current head: $(git rev-parse --short HEAD)"
say "Run logs: $RUN_DIR"
pack_main_tgz
start_server "$HOST_IP"
if [[ "$MODE" == "fresh" || "$MODE" == "both" ]]; then
set +e
run_fresh_main_lane "$SNAPSHOT_ID" "$HOST_IP"
fresh_rc=$?
set -e
if [[ $fresh_rc -eq 0 ]]; then
FRESH_MAIN_STATUS="pass"
else
FRESH_MAIN_STATUS="fail"
fi
fi
if [[ "$MODE" == "upgrade" || "$MODE" == "both" ]]; then
set +e
run_upgrade_lane "$SNAPSHOT_ID" "$HOST_IP"
upgrade_rc=$?
set -e
if [[ $upgrade_rc -eq 0 ]]; then
UPGRADE_STATUS="pass"
else
UPGRADE_STATUS="fail"
fi
fi
if [[ "$KEEP_SERVER" -eq 0 && -n "${SERVER_PID:-}" ]]; then
kill "$SERVER_PID" >/dev/null 2>&1 || true
SERVER_PID=""
fi
SUMMARY_JSON_PATH="$(
SUMMARY_VM="$VM_NAME" \
SUMMARY_SNAPSHOT_HINT="$SNAPSHOT_HINT" \
SUMMARY_SNAPSHOT_ID="$SNAPSHOT_ID" \
SUMMARY_MODE="$MODE" \
SUMMARY_LATEST_VERSION="$LATEST_VERSION" \
SUMMARY_INSTALL_VERSION="$INSTALL_VERSION" \
SUMMARY_TARGET_PACKAGE_SPEC="$TARGET_PACKAGE_SPEC" \
SUMMARY_CURRENT_HEAD="$(git rev-parse --short HEAD)" \
SUMMARY_RUN_DIR="$RUN_DIR" \
SUMMARY_DAEMON_STATUS="$DAEMON_STATUS" \
SUMMARY_FRESH_MAIN_STATUS="$FRESH_MAIN_STATUS" \
SUMMARY_FRESH_MAIN_VERSION="$FRESH_MAIN_VERSION" \
SUMMARY_FRESH_GATEWAY_STATUS="$FRESH_GATEWAY_STATUS" \
SUMMARY_FRESH_AGENT_STATUS="$FRESH_AGENT_STATUS" \
SUMMARY_UPGRADE_STATUS="$UPGRADE_STATUS" \
SUMMARY_LATEST_INSTALLED_VERSION="$LATEST_INSTALLED_VERSION" \
SUMMARY_UPGRADE_MAIN_VERSION="$UPGRADE_MAIN_VERSION" \
SUMMARY_UPGRADE_GATEWAY_STATUS="$UPGRADE_GATEWAY_STATUS" \
SUMMARY_UPGRADE_AGENT_STATUS="$UPGRADE_AGENT_STATUS" \
write_summary_json
)"
if [[ "$JSON_OUTPUT" -eq 1 ]]; then
cat "$SUMMARY_JSON_PATH"
else
printf '\nSummary:\n'
if [[ -n "$TARGET_PACKAGE_SPEC" ]]; then
printf ' target-package: %s\n' "$TARGET_PACKAGE_SPEC"
fi
if [[ -n "$INSTALL_VERSION" ]]; then
printf ' baseline-install-version: %s\n' "$INSTALL_VERSION"
fi
printf ' daemon: %s\n' "$DAEMON_STATUS"
printf ' fresh-main: %s (%s)\n' "$FRESH_MAIN_STATUS" "$FRESH_MAIN_VERSION"
printf ' latest->main: %s (%s)\n' "$UPGRADE_STATUS" "$UPGRADE_MAIN_VERSION"
printf ' logs: %s\n' "$RUN_DIR"
printf ' summary: %s\n' "$SUMMARY_JSON_PATH"
fi
if [[ "$FRESH_MAIN_STATUS" == "fail" || "$UPGRADE_STATUS" == "fail" ]]; then
exit 1
fi