ai-chatbot/scripts/deploy-pascal-production.sh

417 lines
13 KiB
Bash
Executable File

#!/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_SOURCE_DIR="${PASCAL_SOURCE_DIR:-$MAIN_APP_DIR/services/pascal-editor}"
PASCAL_DATA_DIR="${PASCAL_DATA_DIR:-/var/lib/pascal-editor}"
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"
}
install_rsync() {
if command -v rsync >/dev/null 2>&1; then
return
fi
[ "$(id -u)" -eq 0 ] || die "Installing rsync requires root; install rsync manually"
require_command apt-get
apt-get update -qq
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq rsync
}
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
DISABLE_PERF_GUARD_AUTO_INSTALL=1 pnpm install --frozen-lockfile --prod=false
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
)
}
sync_pascal_source() {
[ -f "$PASCAL_SOURCE_DIR/package.json" ] ||
die "Vendored Pascal source not found: $PASCAL_SOURCE_DIR"
[ -f "$PASCAL_SOURCE_DIR/apps/editor/package.json" ] ||
die "Pascal editor package not found below $PASCAL_SOURCE_DIR"
[ "$PASCAL_INSTALL_DIR" != "/" ] || die "PASCAL_INSTALL_DIR cannot be /"
[ "$PASCAL_INSTALL_DIR" != "$MAIN_APP_DIR" ] ||
die "PASCAL_INSTALL_DIR cannot overwrite MAIN_APP_DIR"
[ "$PASCAL_INSTALL_DIR" != "$PASCAL_SOURCE_DIR" ] ||
die "PASCAL_INSTALL_DIR must be separate from PASCAL_SOURCE_DIR"
install_rsync
mkdir -p "$PASCAL_INSTALL_DIR" "$PASCAL_BACKUP_DIR"
if [ -d "$PASCAL_INSTALL_DIR/.git" ]; then
[ -z "$(git -C "$PASCAL_INSTALL_DIR" status --porcelain)" ] ||
die "$PASCAL_INSTALL_DIR has legacy local Git changes; refusing to overwrite them"
local legacy_git_backup="$PASCAL_BACKUP_DIR/legacy-pascal-git-$(date +%Y%m%d%H%M%S)"
log "Archiving legacy Pascal Git metadata to $legacy_git_backup"
mv "$PASCAL_INSTALL_DIR/.git" "$legacy_git_backup"
fi
log "Syncing vendored Pascal source from $PASCAL_SOURCE_DIR"
rsync -a --delete \
--exclude '.env.production' \
--exclude '.git/' \
--exclude '.next/' \
--exclude '.turbo/' \
--exclude 'node_modules/' \
"$PASCAL_SOURCE_DIR/" "$PASCAL_INSTALL_DIR/"
if git -C "$MAIN_APP_DIR" rev-parse --verify HEAD >/dev/null 2>&1; then
git -C "$MAIN_APP_DIR" rev-parse HEAD >"$PASCAL_INSTALL_DIR/.pascal-source-version"
fi
}
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"
if [ -n "$PASCAL_PUBLIC_ORIGIN" ]; then
printf 'PASCAL_SCENE_API_ORIGINS=%s\n' "${PASCAL_PUBLIC_ORIGIN%/}"
fi
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_OUTPUT_TOKENS=%s\n' "${PASCAL_AGENT_MAX_OUTPUT_TOKENS:-8192}"
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 vendored Pascal service"
(
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
sync_pascal_source
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:-<public-origin>}/pascal-editor"
}
main "$@"