diff --git a/deploy/nginx/pascal-service.conf.template b/deploy/nginx/pascal-service.conf.template index 14befbe70..aad0147e5 100644 --- a/deploy/nginx/pascal-service.conf.template +++ b/deploy/nginx/pascal-service.conf.template @@ -3,7 +3,7 @@ location = /_pascal_auth { internal; - proxy_pass http://127.0.0.1:3125/api/internal/pascal/access; + proxy_pass http://__MAIN_NGINX_UPSTREAM__/api/internal/pascal/access; proxy_pass_request_body off; proxy_set_header Content-Length ""; proxy_set_header Host $host; @@ -13,15 +13,15 @@ location = /_pascal_auth { proxy_set_header X-Pascal-Proxy-Secret "__PASCAL_PROXY_SECRET__"; } -location = /pascal-service { - return 302 /pascal-service/scenes; +location = __PASCAL_BASE_PATH__ { + return 302 __PASCAL_BASE_PATH__/scenes; } -location ^~ /pascal-service/ { +location ^~ __PASCAL_BASE_PATH__/ { auth_request /_pascal_auth; auth_request_set $pascal_user_id $upstream_http_x_pascal_user_id; - proxy_pass http://127.0.0.1:3002; + proxy_pass http://__PASCAL_NGINX_UPSTREAM__; proxy_http_version 1.1; proxy_hide_header X-Powered-By; proxy_set_header Host $host; diff --git a/docs/pascal-editor-integration.md b/docs/pascal-editor-integration.md index 7fe9e6baf..578db886f 100644 --- a/docs/pascal-editor-integration.md +++ b/docs/pascal-editor-integration.md @@ -115,6 +115,11 @@ and provider from system settings on every request. OpenAI-compatible `openai`, `custom`, `alibaba`, and `zhipu` providers work without copying their API keys into the Pascal service. +For repeatable deployment and upgrades on additional servers, use +`scripts/deploy-pascal-production.sh`. The complete defaults, override +variables, health check, real-model smoke test, and rollback behavior are +documented in `docs/pascal-production-deployment.md`. + ## Next Step After the iframe POC is stable, the native integration path is: diff --git a/docs/pascal-production-deployment.md b/docs/pascal-production-deployment.md new file mode 100644 index 000000000..8641c76e3 --- /dev/null +++ b/docs/pascal-production-deployment.md @@ -0,0 +1,111 @@ +# Pascal Editor 可复用生产部署 + +本项目把 Pascal Editor 作为独立 Bun/Next.js 服务运行,并通过主站 Nginx +同域挂载。部署脚本会复用主站登录态、系统设置中的 Agent 文本模型和主站 +Git 仓库里的 `pascal-editor-ai-agent` 独立分支。 + +## 默认结构 + +| 项目 | 默认值 | +| --- | --- | +| 主站目录 | 当前仓库目录 | +| 主站 PM2 服务 | `ai-chatbot` | +| 主站内部地址 | `http://127.0.0.1:3125` | +| Pascal 源码 | `/opt/pascal-editor` | +| Pascal 数据 | `/var/lib/pascal-editor` | +| Pascal PM2 服务 | `pascal-editor` | +| Pascal 监听 | `127.0.0.1:3002` | +| 同域路径 | `/pascal-service` | +| Pascal Git 分支 | `pascal-editor-ai-agent` | + +Pascal 端口不对公网开放。Nginx 会先通过主站 +`/api/internal/pascal/access` 校验登录 Cookie,再把可信用户 ID 注入 Pascal。 +场景的创建、列表、读取、保存、删除、事件流和 AI Agent 都检查同一个 +`ownerId`。 + +## 首次部署 + +在目标服务器先部署好主站并确认主站 Git remote 能读取 +`pascal-editor-ai-agent` 分支,然后执行: + +```bash +cd /var/www/ai-chatbot +git pull --ff-only origin main + +sudo env \ + PASCAL_PUBLIC_ORIGIN=https://example.com \ + NGINX_SITE_CONFIG=/etc/nginx/sites-enabled/example.com.conf \ + pnpm pascal:deploy +``` + +脚本会按顺序: + +1. 生成或复用 `PASCAL_PROXY_SECRET`,写入主站 `.env.local`。 +2. 构建并重启主站。 +3. 安装固定版本 Bun(默认 `1.3.0`)。 +4. 克隆或快进 Pascal 独立分支。 +5. 写入仅 root 可读的 Pascal 生产环境文件。 +6. 安装依赖、构建 Pascal,并注册 PM2 服务。 +7. 渲染 Nginx 片段、执行 `nginx -t`,成功后热重载。 +8. 检查主站、Pascal、Agent 状态和主站模型网关。 + +主站环境文件和 Nginx 配置会备份到 `/var/backups/pascal-deploy`。Nginx +校验失败时,脚本会立即恢复执行前的配置。 + +## 日常更新 + +主站和 Pascal 分支有新提交后,重复执行同一个命令即可。脚本只允许 Git +快进更新;如果 `/opt/pascal-editor` 有本地修改,会停止部署,不会覆盖现场 +文件。 + +只做无模型费用的健康检查: + +```bash +sudo env \ + PASCAL_PUBLIC_ORIGIN=https://example.com \ + pnpm pascal:check +``` + +执行一次真实自然语言建模、验证节点已落库并自动删除临时场景: + +```bash +sudo env PASCAL_SMOKE_CONFIRM=1 pnpm pascal:smoke +``` + +真实冒烟会调用当前系统设置中的 Agent 模型,因此默认不会在一键部署时 +执行。需要每次部署自动执行时设置 `RUN_AGENT_SMOKE_TEST=true`。 + +## 常用覆盖项 + +| 环境变量 | 用途 | +| --- | --- | +| `MAIN_APP_DIR` | 主站源码目录 | +| `MAIN_ENV_FILE` | 主站环境文件 | +| `MAIN_PM2_NAME` | 主站 PM2 服务名 | +| `MAIN_INTERNAL_URL` | Pascal 调用主站网关的地址 | +| `MAIN_NGINX_UPSTREAM` | Nginx 主站 upstream,例如 `127.0.0.1:3125` | +| `BUILD_MAIN_APP=false` | 跳过主站安装、构建和重启 | +| `PASCAL_INSTALL_DIR` | Pascal 源码目录 | +| `PASCAL_DATA_DIR` | SQLite 数据目录 | +| `PASCAL_GIT_REPO` | Pascal 分支所在 Git 仓库;默认使用主站 origin | +| `PASCAL_GIT_REF` | Pascal 分支或标签 | +| `PASCAL_PORT` | Pascal 本地监听端口 | +| `PASCAL_BASE_PATH` | 同域挂载路径 | +| `PASCAL_PUBLIC_ORIGIN` | 公网主站 Origin,不带末尾斜杠 | +| `NGINX_SITE_CONFIG` | 需要加入 Pascal include 的 HTTPS server 配置 | +| `INSTALL_NGINX=false` | 仅部署服务,不修改 Nginx | +| `PASCAL_BUN_VERSION` | 固定 Bun 版本 | +| `PASCAL_BACKUP_DIR` | 环境与 Nginx 配置备份目录 | + +## 运行维护 + +```bash +pm2 status +pm2 logs pascal-editor --lines 100 +curl http://127.0.0.1:3002/pascal-service/api/health +nginx -t +``` + +升级时不要把主站源码复制到 Pascal 目录,也不要把本地测试数据库上传到 +服务器。主站始终更新 `main`,Pascal 始终更新独立分支,用户场景只保存在 +`PASCAL_DATA_DIR`。 diff --git a/package.json b/package.json index 7de895af7..1c4996806 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,9 @@ "scripts": { "dev": "next dev --turbo", "pascal:dev": "node scripts/pascal-editor-dev.mjs", + "pascal:deploy": "bash scripts/deploy-pascal-production.sh deploy", + "pascal:check": "bash scripts/deploy-pascal-production.sh check", + "pascal:smoke": "bash scripts/smoke-pascal-agent.sh", "postinstall": "node scripts/security/auto-enable-perf-guard.mjs", "prebuild": "node scripts/security/auto-enable-perf-guard.mjs", "build": "next build", diff --git a/scripts/deploy-pascal-production.sh b/scripts/deploy-pascal-production.sh new file mode 100755 index 000000000..275d4680b --- /dev/null +++ b/scripts/deploy-pascal-production.sh @@ -0,0 +1,391 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +MODE="${1:-deploy}" +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +MAIN_APP_DIR="${MAIN_APP_DIR:-$ROOT_DIR}" +MAIN_ENV_FILE="${MAIN_ENV_FILE:-$MAIN_APP_DIR/.env.local}" +MAIN_PM2_NAME="${MAIN_PM2_NAME:-ai-chatbot}" +MAIN_INTERNAL_URL="${MAIN_INTERNAL_URL:-http://127.0.0.1:3125}" +MAIN_NGINX_UPSTREAM="${MAIN_NGINX_UPSTREAM:-127.0.0.1:3125}" +BUILD_MAIN_APP="${BUILD_MAIN_APP:-true}" + +PASCAL_INSTALL_DIR="${PASCAL_INSTALL_DIR:-/opt/pascal-editor}" +PASCAL_DATA_DIR="${PASCAL_DATA_DIR:-/var/lib/pascal-editor}" +PASCAL_GIT_REF="${PASCAL_GIT_REF:-pascal-editor-ai-agent}" +PASCAL_GIT_REPO="${PASCAL_GIT_REPO:-}" +PASCAL_PM2_NAME="${PASCAL_PM2_NAME:-pascal-editor}" +PASCAL_PORT="${PASCAL_PORT:-3002}" +PASCAL_NGINX_UPSTREAM="${PASCAL_NGINX_UPSTREAM:-127.0.0.1:${PASCAL_PORT}}" +PASCAL_BASE_PATH="${PASCAL_BASE_PATH:-/pascal-service}" +PASCAL_PUBLIC_ORIGIN="${PASCAL_PUBLIC_ORIGIN:-}" +PASCAL_BUN_VERSION="${PASCAL_BUN_VERSION:-1.3.0}" +PASCAL_BUN_BIN="${PASCAL_BUN_BIN:-$HOME/.bun/bin/bun}" + +NGINX_SITE_CONFIG="${NGINX_SITE_CONFIG:-}" +NGINX_SNIPPET_PATH="${NGINX_SNIPPET_PATH:-/etc/nginx/snippets/pascal-service.conf}" +INSTALL_NGINX="${INSTALL_NGINX:-true}" +RUN_AGENT_SMOKE_TEST="${RUN_AGENT_SMOKE_TEST:-false}" +PASCAL_BACKUP_DIR="${PASCAL_BACKUP_DIR:-/var/backups/pascal-deploy}" + +log() { + printf '[pascal-deploy] %s\n' "$*" +} + +die() { + printf '[pascal-deploy] ERROR: %s\n' "$*" >&2 + exit 1 +} + +is_true() { + case "$1" in + 1 | true | TRUE | True | yes | YES | Yes | on | ON | On) return 0 ;; + *) return 1 ;; + esac +} + +require_command() { + command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" +} + +read_env_value() { + local key="$1" + local file="$2" + [ -f "$file" ] || return 0 + sed -n "s/^${key}=//p" "$file" | tail -n 1 +} + +upsert_env_value() { + local key="$1" + local value="$2" + local file="$3" + local temp + mkdir -p "$(dirname "$file")" + touch "$file" + temp="$(mktemp)" + awk -v prefix="${key}=" 'index($0, prefix) != 1 { print }' "$file" >"$temp" + printf '%s=%s\n' "$key" "$value" >>"$temp" + mv "$temp" "$file" + chmod 600 "$file" +} + +generate_secret() { + if command -v openssl >/dev/null 2>&1; then + openssl rand -hex 32 + return + fi + od -An -N32 -tx1 /dev/urandom | tr -d ' \n' +} + +resolve_secret() { + local configured="${PASCAL_PROXY_SECRET:-}" + if [ -z "$configured" ]; then + configured="$(read_env_value PASCAL_PROXY_SECRET "$MAIN_ENV_FILE")" + fi + if [ -z "$configured" ] && [ "$MODE" = "deploy" ]; then + configured="$(generate_secret)" + fi + [[ "$configured" =~ ^[A-Za-z0-9_-]{32,128}$ ]] || + die "PASCAL_PROXY_SECRET must contain 32-128 letters, numbers, underscores, or hyphens" + printf '%s' "$configured" +} + +normalize_base_path() { + local value="${1#/}" + value="${value%/}" + [ -n "$value" ] || die "PASCAL_BASE_PATH cannot be empty" + printf '/%s' "$value" +} + +resolve_public_host() { + local value="${PASCAL_PUBLIC_ORIGIN#*://}" + value="${value%%/*}" + value="${value%%:*}" + printf '%s' "$value" +} + +install_bun() { + if [ -x "$PASCAL_BUN_BIN" ] && + [ "$($PASCAL_BUN_BIN --version 2>/dev/null || true)" = "$PASCAL_BUN_VERSION" ]; then + return + fi + + [ "$(id -u)" -eq 0 ] || die "Installing Bun requires root; install Bun $PASCAL_BUN_VERSION manually" + require_command curl + if ! command -v unzip >/dev/null 2>&1; then + require_command apt-get + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq unzip + fi + + local installer + installer="$(mktemp)" + curl -fsSL https://bun.sh/install -o "$installer" + bash "$installer" "bun-v${PASCAL_BUN_VERSION}" + rm -f "$installer" + [ -x "$PASCAL_BUN_BIN" ] || die "Bun installation did not create $PASCAL_BUN_BIN" +} + +configure_main_app() { + local secret="$1" + local next_env_backup="" + mkdir -p "$PASCAL_BACKUP_DIR" + if [ -f "$MAIN_ENV_FILE" ]; then + cp "$MAIN_ENV_FILE" "$PASCAL_BACKUP_DIR/main-env-$(date +%Y%m%d%H%M%S).bak" + chmod 600 "$PASCAL_BACKUP_DIR"/main-env-*.bak + fi + upsert_env_value PASCAL_EDITOR_URL "${PASCAL_BASE_PATH}/scenes" "$MAIN_ENV_FILE" + upsert_env_value PASCAL_PROXY_SECRET "$secret" "$MAIN_ENV_FILE" + + if ! is_true "$BUILD_MAIN_APP"; then + log "Skipping main application build (BUILD_MAIN_APP=$BUILD_MAIN_APP)" + return + fi + + require_command pnpm + require_command pm2 + log "Building main application in $MAIN_APP_DIR" + if [ -f "$MAIN_APP_DIR/next-env.d.ts" ]; then + next_env_backup="$(mktemp)" + cp "$MAIN_APP_DIR/next-env.d.ts" "$next_env_backup" + fi + local build_status=0 + set +e + ( + cd "$MAIN_APP_DIR" + set -a + # shellcheck disable=SC1090 + source "$MAIN_ENV_FILE" + set +a + pnpm install --frozen-lockfile + DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm build + ) || build_status=$? + set -e + if [ -n "$next_env_backup" ]; then + cp "$next_env_backup" "$MAIN_APP_DIR/next-env.d.ts" + rm -f "$next_env_backup" + fi + [ "$build_status" -eq 0 ] || die "Main application build failed" + ( + set -a + # shellcheck disable=SC1090 + source "$MAIN_ENV_FILE" + set +a + pm2 restart "$MAIN_PM2_NAME" --update-env + ) +} + +resolve_pascal_repo() { + if [ -n "$PASCAL_GIT_REPO" ]; then + printf '%s' "$PASCAL_GIT_REPO" + return + fi + git -C "$MAIN_APP_DIR" remote get-url origin +} + +checkout_pascal() { + local repo="$1" + if [ -d "$PASCAL_INSTALL_DIR/.git" ]; then + [ -z "$(git -C "$PASCAL_INSTALL_DIR" status --porcelain)" ] || + die "$PASCAL_INSTALL_DIR has local Git changes; refusing to overwrite them" + git -C "$PASCAL_INSTALL_DIR" fetch origin "$PASCAL_GIT_REF" + git -C "$PASCAL_INSTALL_DIR" checkout "$PASCAL_GIT_REF" + git -C "$PASCAL_INSTALL_DIR" pull --ff-only origin "$PASCAL_GIT_REF" + return + fi + + [ ! -e "$PASCAL_INSTALL_DIR" ] || + die "$PASCAL_INSTALL_DIR exists but is not a Git checkout" + mkdir -p "$(dirname "$PASCAL_INSTALL_DIR")" + git clone --single-branch --branch "$PASCAL_GIT_REF" "$repo" "$PASCAL_INSTALL_DIR" +} + +write_pascal_env() { + local secret="$1" + local env_file="$PASCAL_INSTALL_DIR/.env.production" + umask 077 + { + printf 'NODE_ENV=production\n' + printf 'NEXT_PUBLIC_PASCAL_BASE_PATH=%s\n' "$PASCAL_BASE_PATH" + printf 'PASCAL_REQUIRE_HOST_USER=true\n' + printf 'PASCAL_DATA_DIR=%s\n' "$PASCAL_DATA_DIR" + printf 'PASCAL_SCENE_API_TOKEN=%s\n' "$secret" + printf 'PASCAL_AGENT_PROXY_SECRET=%s\n' "$secret" + printf 'PASCAL_AGENT_API_KEY=%s\n' "$secret" + printf 'PASCAL_AGENT_BASE_URL=%s/api/internal/pascal-agent/v1\n' "$MAIN_INTERNAL_URL" + printf 'PASCAL_AGENT_MODEL=main-agent\n' + printf 'PASCAL_AGENT_MAX_STEPS=%s\n' "${PASCAL_AGENT_MAX_STEPS:-18}" + printf 'PASCAL_AGENT_STEP_TIMEOUT_MS=%s\n' "${PASCAL_AGENT_STEP_TIMEOUT_MS:-90000}" + printf 'PASCAL_AGENT_TOTAL_TIMEOUT_MS=%s\n' "${PASCAL_AGENT_TOTAL_TIMEOUT_MS:-300000}" + } >"$env_file" + chmod 600 "$env_file" +} + +build_pascal() { + mkdir -p "$PASCAL_DATA_DIR" + chmod 700 "$PASCAL_DATA_DIR" + export PATH="$(dirname "$PASCAL_BUN_BIN"):$PATH" + log "Installing and building Pascal from $PASCAL_GIT_REF" + ( + cd "$PASCAL_INSTALL_DIR" + "$PASCAL_BUN_BIN" install --frozen-lockfile + set -a + # shellcheck disable=SC1091 + source .env.production + set +a + "$PASCAL_BUN_BIN" x turbo run build --filter=editor --output-logs=errors-only + ) +} + +start_pascal() { + require_command pm2 + if pm2 describe "$PASCAL_PM2_NAME" >/dev/null 2>&1; then + pm2 restart "$PASCAL_PM2_NAME" + else + pm2 start /usr/bin/bash \ + --name "$PASCAL_PM2_NAME" \ + --cwd "$PASCAL_INSTALL_DIR" \ + -- -lc "set -a; source '$PASCAL_INSTALL_DIR/.env.production'; set +a; export PATH='$(dirname "$PASCAL_BUN_BIN")':\$PATH; exec '$PASCAL_BUN_BIN' run --cwd apps/editor start -- -p '$PASCAL_PORT' -H 127.0.0.1" + fi + pm2 save +} + +render_nginx() { + local secret="$1" + local public_host + public_host="$(resolve_public_host)" + + if ! is_true "$INSTALL_NGINX"; then + log "Skipping Nginx setup (INSTALL_NGINX=$INSTALL_NGINX)" + return + fi + [ -n "$public_host" ] || die "PASCAL_PUBLIC_ORIGIN is required when INSTALL_NGINX=true" + + if [ -z "$NGINX_SITE_CONFIG" ]; then + NGINX_SITE_CONFIG="/etc/nginx/sites-enabled/${public_host}.conf" + fi + [ -f "$NGINX_SITE_CONFIG" ] || die "Nginx site config not found: $NGINX_SITE_CONFIG" + require_command nginx + + local template="$MAIN_APP_DIR/deploy/nginx/pascal-service.conf.template" + local site_backup snippet_backup rendered updated_site + [ -f "$template" ] || die "Nginx template not found: $template" + site_backup="$(mktemp)" + snippet_backup="$(mktemp)" + rendered="$(mktemp)" + updated_site="$(mktemp)" + cp "$NGINX_SITE_CONFIG" "$site_backup" + mkdir -p "$PASCAL_BACKUP_DIR" + cp "$NGINX_SITE_CONFIG" "$PASCAL_BACKUP_DIR/nginx-site-$(date +%Y%m%d%H%M%S).bak" + if [ -f "$NGINX_SNIPPET_PATH" ]; then + cp "$NGINX_SNIPPET_PATH" "$snippet_backup" + cp "$NGINX_SNIPPET_PATH" "$PASCAL_BACKUP_DIR/nginx-snippet-$(date +%Y%m%d%H%M%S).bak" + else + : >"$snippet_backup" + fi + + sed \ + -e "s/__PASCAL_PROXY_SECRET__/${secret}/g" \ + -e "s#__MAIN_NGINX_UPSTREAM__#${MAIN_NGINX_UPSTREAM}#g" \ + -e "s#__PASCAL_NGINX_UPSTREAM__#${PASCAL_NGINX_UPSTREAM}#g" \ + -e "s#__PASCAL_BASE_PATH__#${PASCAL_BASE_PATH}#g" \ + "$template" >"$rendered" + install -m 600 "$rendered" "$NGINX_SNIPPET_PATH" + + if ! grep -qF "include $NGINX_SNIPPET_PATH;" "$NGINX_SITE_CONFIG"; then + awk -v target="server_name ${public_host};" -v include_line=" include ${NGINX_SNIPPET_PATH};" ' + !inserted && index($0, target) > 0 { print; print include_line; inserted = 1; next } + { print } + END { if (!inserted) exit 42 } + ' "$NGINX_SITE_CONFIG" >"$updated_site" || { + cp "$site_backup" "$NGINX_SITE_CONFIG" + die "Could not find server_name ${public_host}; in $NGINX_SITE_CONFIG" + } + install -m 644 "$updated_site" "$NGINX_SITE_CONFIG" + fi + + if ! nginx -t; then + cp "$site_backup" "$NGINX_SITE_CONFIG" + if [ -s "$snippet_backup" ]; then + cp "$snippet_backup" "$NGINX_SNIPPET_PATH" + else + rm -f "$NGINX_SNIPPET_PATH" + fi + nginx -t || true + die "Nginx validation failed; previous configuration restored" + fi + systemctl reload nginx + rm -f "$site_backup" "$snippet_backup" "$rendered" "$updated_site" +} + +check_deployment() { + local secret="$1" + local local_pascal="http://127.0.0.1:${PASCAL_PORT}${PASCAL_BASE_PATH}" + log "Checking main application" + curl -fsS "$MAIN_INTERNAL_URL/ping" >/dev/null + log "Checking Pascal health and Agent configuration" + curl -fsS "$local_pascal/api/health" >/dev/null + curl -fsS \ + -H "X-Pascal-Agent-Proxy-Secret: $secret" \ + -H "X-Pascal-User-Id: deployment-health-check" \ + "$local_pascal/api/agent/chat" >/dev/null + log "Checking the main model gateway" + curl -fsS \ + -H "Authorization: Bearer $secret" \ + "$MAIN_INTERNAL_URL/api/internal/pascal-agent/v1/chat/completions" + printf '\n' + + if [ -n "$PASCAL_PUBLIC_ORIGIN" ]; then + local public_code + public_code="$(curl -sS -o /dev/null -w '%{http_code}' "${PASCAL_PUBLIC_ORIGIN}${PASCAL_BASE_PATH}/scenes")" + case "$public_code" in + 200 | 302 | 307 | 401) ;; + *) die "Unexpected public Pascal status: $public_code" ;; + esac + fi + + log "Deployment checks passed" +} + +main() { + case "$MODE" in + deploy | check) ;; + *) die "Usage: $0 [deploy|check]" ;; + esac + + require_command curl + require_command git + require_command sed + PASCAL_BASE_PATH="$(normalize_base_path "$PASCAL_BASE_PATH")" + local secret + secret="$(resolve_secret)" + + if [ "$MODE" = "check" ]; then + check_deployment "$secret" + return + fi + + [ "$(id -u)" -eq 0 ] || die "Production deployment must run as root" + configure_main_app "$secret" + install_bun + checkout_pascal "$(resolve_pascal_repo)" + write_pascal_env "$secret" + build_pascal + start_pascal + render_nginx "$secret" + sleep 3 + check_deployment "$secret" + + if is_true "$RUN_AGENT_SMOKE_TEST"; then + PASCAL_SMOKE_CONFIRM=1 \ + PASCAL_ENV_FILE="$PASCAL_INSTALL_DIR/.env.production" \ + PASCAL_PORT="$PASCAL_PORT" \ + PASCAL_BASE_PATH="$PASCAL_BASE_PATH" \ + "$MAIN_APP_DIR/scripts/smoke-pascal-agent.sh" + fi + + log "Pascal is ready at ${PASCAL_PUBLIC_ORIGIN:-}/pascal-editor" +} + +main "$@" diff --git a/scripts/smoke-pascal-agent.sh b/scripts/smoke-pascal-agent.sh new file mode 100755 index 000000000..f70eef9af --- /dev/null +++ b/scripts/smoke-pascal-agent.sh @@ -0,0 +1,100 @@ +#!/usr/bin/env bash +set -Eeuo pipefail + +if [ "${PASCAL_SMOKE_CONFIRM:-}" != "1" ]; then + printf '%s\n' \ + 'This check makes one real model request and then deletes its temporary scene.' \ + 'Run with PASCAL_SMOKE_CONFIRM=1 after confirming provider usage is acceptable.' >&2 + exit 2 +fi + +command -v curl >/dev/null 2>&1 || { printf 'curl is required.\n' >&2; exit 1; } +command -v node >/dev/null 2>&1 || { printf 'node is required.\n' >&2; exit 1; } + +PASCAL_ENV_FILE="${PASCAL_ENV_FILE:-/opt/pascal-editor/.env.production}" +PASCAL_PORT="${PASCAL_PORT:-3002}" +PASCAL_BASE_PATH="/${PASCAL_BASE_PATH#/}" +PASCAL_BASE_PATH="${PASCAL_BASE_PATH%/}" +BASE_URL="${PASCAL_INTERNAL_URL:-http://127.0.0.1:${PASCAL_PORT}}${PASCAL_BASE_PATH}" +USER_ID="deployment-smoke-test" +SCENE_ID="deployment-smoke-scene" + +read_env_value() { + sed -n "s/^${1}=//p" "$PASCAL_ENV_FILE" | tail -n 1 +} + +SECRET="${PASCAL_PROXY_SECRET:-$(read_env_value PASCAL_AGENT_PROXY_SECRET)}" +[ -n "$SECRET" ] || { + printf 'PASCAL proxy secret is not configured.\n' >&2 + exit 1 +} + +stream_file="$(mktemp)" +scene_file="$(mktemp)" +prompt="${PASCAL_SMOKE_PROMPT:-创建一个4米乘4米的简单房间,添加一扇门和一扇窗,检查结构后保存。}" + +cleanup() { + curl -sS -o /dev/null -X DELETE \ + -H "X-Pascal-Scene-Token: $SECRET" \ + -H "X-Pascal-User-Id: $USER_ID" \ + "$BASE_URL/api/scenes/$SCENE_ID" || true + rm -f "$stream_file" "$scene_file" +} +trap cleanup EXIT + +cleanup + +payload="$(node -e ' + const sceneId = process.argv[1]; + const prompt = process.argv[2]; + process.stdout.write(JSON.stringify({ + sceneId, + messages: [{ + id: "deployment-smoke-message", + role: "user", + parts: [{ type: "text", text: prompt }], + }], + })); +' "$SCENE_ID" "$prompt")" + +curl -fsS -X POST \ + -H 'Content-Type: application/json' \ + -H "X-Pascal-Scene-Token: $SECRET" \ + -H "X-Pascal-User-Id: $USER_ID" \ + --data "{\"id\":\"$SCENE_ID\",\"name\":\"Deployment smoke test\",\"graph\":{\"nodes\":{},\"rootNodeIds\":[]}}" \ + "$BASE_URL/api/scenes" >/dev/null + +http_code="$(curl -sS --max-time "${PASCAL_SMOKE_TIMEOUT_SECONDS:-360}" \ + -o "$stream_file" \ + -w '%{http_code}' \ + -X POST \ + -H 'Content-Type: application/json' \ + -H "X-Pascal-Agent-Proxy-Secret: $SECRET" \ + -H "X-Pascal-User-Id: $USER_ID" \ + --data "$payload" \ + "$BASE_URL/api/agent/chat")" + +[ "$http_code" = "200" ] || { + printf 'Agent smoke request returned HTTP %s.\n' "$http_code" >&2 + exit 1 +} +if grep -q '"type":"error"' "$stream_file"; then + printf 'Agent smoke stream reported an error.\n' >&2 + exit 1 +fi + +curl -fsS \ + -H "X-Pascal-Scene-Token: $SECRET" \ + -H "X-Pascal-User-Id: $USER_ID" \ + "$BASE_URL/api/scenes/$SCENE_ID" >"$scene_file" + +node -e ' + const fs = require("node:fs"); + const scene = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); + const expectedOwner = process.argv[2]; + if (!(scene.nodeCount > 0)) throw new Error("Agent did not persist scene nodes"); + if (scene.ownerId !== expectedOwner) throw new Error("Scene owner mismatch"); + console.log(JSON.stringify({ nodeCount: scene.nodeCount, ownerId: scene.ownerId, version: scene.version })); +' "$scene_file" "$USER_ID" + +printf '[pascal-smoke] Real model and MCP persistence check passed.\n'