416 lines
13 KiB
Bash
Executable File
416 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_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 "$@"
|