8d99f7e10f
Co-Authored-By: Claude Code <noreply@anthropic.com>
178 lines
7.0 KiB
Bash
178 lines
7.0 KiB
Bash
#!/usr/bin/env bash
|
||
# 客迹一键部署——固化标准部署链(spec: docs/superpowers/specs/2026-09-29-git-and-deploy-automation-design.md)
|
||
# 用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]
|
||
# 红线: 只操作 /opt/cms-cj 与 tracker-web 容器,绝不触碰服务器其他项目
|
||
set -euo pipefail
|
||
SECONDS=0
|
||
|
||
# ---- 配置(环境变量可覆盖)----
|
||
JUMP_HOST="${JUMP_HOST:-root@8.152.213.143}"
|
||
TARGET_HOST="${TARGET_HOST:-root@47.254.176.10}"
|
||
SSH_KEY="${SSH_KEY:-$HOME/.ssh/id_ed25519}"
|
||
REMOTE_DIR="${REMOTE_DIR:-/opt/cms-cj}"
|
||
SITE_URL="${SITE_URL:-https://tracker.12418113.xyz}"
|
||
|
||
REPO_DIR="$(cd "$(dirname "$0")/.." && pwd)"
|
||
LOG_DIR="$REPO_DIR/deploy/logs"
|
||
STAMP="$(date +%Y%m%d-%H%M%S)"
|
||
LOG_FILE="$LOG_DIR/deploy-$STAMP.log"
|
||
|
||
SSH_OPTS=(-i "$SSH_KEY" -o ConnectTimeout=15 -o BatchMode=yes)
|
||
SSH=(ssh "${SSH_OPTS[@]}" -J "$JUMP_HOST" "$TARGET_HOST")
|
||
SCP=(scp "${SSH_OPTS[@]}" -J "$JUMP_HOST")
|
||
|
||
log() { echo "[$(date +%H:%M:%S)] $*" | tee -a "$LOG_FILE"; }
|
||
die() { log "✗ 失败: $*(部署中止,此前已完成的目标保持已部署状态)"; exit 1; }
|
||
ok() { log "✓ $*"; }
|
||
|
||
mkdir -p "$LOG_DIR" # 必须先于参数解析——早期 die() 也要能写日志
|
||
|
||
# ---- 参数解析 ----
|
||
TARGET_CURRENT="preflight"; STEP="-"
|
||
[ $# -ge 1 ] || die "用法: bash deploy/deploy.sh <server|admin|sdk|all> [--skip-tests] [--allow-dirty]"
|
||
DEPLOY_TARGET="$1"; shift || true
|
||
SKIP_TESTS=0; ALLOW_DIRTY=0
|
||
for arg in "$@"; do
|
||
case "$arg" in
|
||
--skip-tests) SKIP_TESTS=1 ;;
|
||
--allow-dirty) ALLOW_DIRTY=1 ;;
|
||
*) die "未知参数: $arg" ;;
|
||
esac
|
||
done
|
||
case "$DEPLOY_TARGET" in
|
||
server|admin|sdk|all) ;;
|
||
*) die "目标必须是 server|admin|sdk|all,收到: $DEPLOY_TARGET" ;;
|
||
esac
|
||
|
||
GIT_HASH="$(git -C "$REPO_DIR" rev-parse --short HEAD)"
|
||
log "===== 部署开始 target=$DEPLOY_TARGET git=$GIT_HASH ====="
|
||
|
||
# ---- 前置检查(所有目标必经)----
|
||
preflight() {
|
||
STEP="前置检查"
|
||
if [ "$ALLOW_DIRTY" -eq 0 ] && [ -n "$(git -C "$REPO_DIR" status --porcelain)" ]; then
|
||
die "git 工作区不干净(--allow-dirty 可跳过;强烈建议先提交)"
|
||
fi
|
||
"${SSH[@]}" 'echo ok' >/dev/null 2>&1 || die "跳板/目标服务器 SSH 不可达($JUMP_HOST → $TARGET_HOST)"
|
||
ok "工作区干净,SSH 链路可达"
|
||
}
|
||
|
||
# ---- 各目标实现 ----
|
||
# server 测试解释器:优先项目 venv(Windows Scripts/ 或 Linux bin/ 布局),退化到 PATH python
|
||
server_py() {
|
||
if [ -x "$REPO_DIR/server/.venv/Scripts/python.exe" ]; then echo "$REPO_DIR/server/.venv/Scripts/python.exe"
|
||
elif [ -x "$REPO_DIR/server/.venv/bin/python" ]; then echo "$REPO_DIR/server/.venv/bin/python"
|
||
else echo python; fi
|
||
}
|
||
server_tests() {
|
||
[ "$SKIP_TESTS" -eq 1 ] && return 0
|
||
STEP="server 测试"
|
||
local py; py="$(server_py)"
|
||
(cd "$REPO_DIR/server" && "$py" -m pytest tracker/tests/ -q >>"$LOG_FILE" 2>&1) \
|
||
|| die "pytest 未全过(详见 $LOG_FILE)"
|
||
ok "pytest 全过"
|
||
}
|
||
sdk_tests() {
|
||
[ "$SKIP_TESTS" -eq 1 ] && return 0
|
||
STEP="sdk 测试"
|
||
(cd "$REPO_DIR/sdk" && npx vitest run >>"$LOG_FILE" 2>&1) || die "vitest 未全过"
|
||
(cd "$REPO_DIR/sdk" && npx tsc --noEmit >>"$LOG_FILE" 2>&1) || die "tsc 报错"
|
||
ok "sdk vitest+tsc 全过"
|
||
}
|
||
build_admin() {
|
||
STEP="admin 构建"
|
||
(cd "$REPO_DIR/admin" && NODE_OPTIONS=--max-old-space-size=6144 pnpm build >>"$LOG_FILE" 2>&1) \
|
||
|| die "admin 构建失败(本机 OOM 则确认 NODE_OPTIONS 已带)"
|
||
ok "admin 构建完成: $(grep -oE 'index-[A-Za-z0-9_-]+\.js' "$REPO_DIR/admin/dist/index.html" | head -1)"
|
||
}
|
||
build_sdk() {
|
||
STEP="sdk 构建"
|
||
(cd "$REPO_DIR/sdk" && node build.mjs >>"$LOG_FILE" 2>&1) || die "sdk 构建失败"
|
||
local gz; gz=$(gzip -c "$REPO_DIR/sdk/dist/v1.js" | wc -c)
|
||
ok "sdk 构建完成: build_id=$(grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' "$REPO_DIR/sdk/dist/v1.js" | head -1) gzip=${gz}B"
|
||
}
|
||
transfer() { # $1=包内路径列表(空格分隔,相对 REPO_DIR) $2=远端包名
|
||
STEP="传输与落位"
|
||
local tarball="/tmp/$2"
|
||
(cd "$REPO_DIR" && tar -czf "$tarball" $1) || die "tar 打包失败: $1"
|
||
"${SCP[@]}" "$tarball" "$TARGET_HOST:/tmp/" >/dev/null 2>&1 || die "scp 传输失败(经 $JUMP_HOST)"
|
||
rm -f "$tarball"
|
||
# tar 按真实路径解压——直接落位挂载点(原地合并,无 cp 无暂存)
|
||
"${SSH[@]}" "cd $REMOTE_DIR && tar -xzf /tmp/$2 && rm -f /tmp/$2" >/dev/null 2>&1 \
|
||
|| die "远端解压失败($REMOTE_DIR)"
|
||
ok "已落位: $1"
|
||
}
|
||
rebuild_web() {
|
||
STEP="server 容器重建"
|
||
"${SSH[@]}" "cd $REMOTE_DIR/deploy && docker compose up -d --build web" >>"$LOG_FILE" 2>&1 \
|
||
|| die "docker compose 重建失败"
|
||
sleep 5
|
||
local status; status="$("${SSH[@]}" "docker ps --filter name=tracker-web --format '{{.Status}}'")" \
|
||
|| die "容器状态查询失败"
|
||
echo "$status" | grep -q "^Up" || die "tracker-web 容器状态异常: $status"
|
||
ok "tracker-web: $status"
|
||
}
|
||
verify_site_up() {
|
||
STEP="站点探活"
|
||
curl -sf -o /dev/null --max-time 20 "$SITE_URL" || die "$SITE_URL 探活失败"
|
||
ok "站点 200"
|
||
}
|
||
verify_admin_hash() {
|
||
STEP="admin 哈希验证"
|
||
local local_h online_h
|
||
local_h=$(sha256sum "$REPO_DIR/admin/dist/index.html" | cut -d' ' -f1)
|
||
online_h=$(curl -sf --max-time 20 "$SITE_URL/" | sha256sum | cut -d' ' -f1) \
|
||
|| die "线上 index.html 拉取失败"
|
||
[ "$local_h" = "$online_h" ] || die "admin index.html 线上哈希与本机产物不一致(部署未生效或缓存)"
|
||
ok "admin 哈希一致"
|
||
}
|
||
verify_sdk_bid() {
|
||
STEP="sdk build_id 验证"
|
||
local bid; bid="$(grep -oE '[0-9a-f]{7,9}-2026[0-9]{8}' "$REPO_DIR/sdk/dist/v1.js" | head -1)"
|
||
[ -n "$bid" ] || die "本机产物无 build_id"
|
||
curl -sf --max-time 20 "$SITE_URL/sdk/v1.js" | grep -q "$bid" \
|
||
|| die "线上 v1.js 不含新 build_id $bid"
|
||
ok "sdk 线上已含 build_id $bid"
|
||
}
|
||
|
||
deploy_server() {
|
||
TARGET_CURRENT=server
|
||
server_tests
|
||
STEP="server 打包"
|
||
# 排除清单镜像 server/.dockerignore 本意:Windows venv(97MB)/缓存/本地xdb 绝不上服务器
|
||
(cd "$REPO_DIR" && tar -czf /tmp/keji-server.tgz \
|
||
--exclude='__pycache__' --exclude='.pytest_cache' --exclude='db.sqlite3' \
|
||
--exclude='.venv' --exclude='.cache' --exclude='tracker/data/*.xdb' server/) \
|
||
|| die "server 打包失败"
|
||
"${SCP[@]}" /tmp/keji-server.tgz "$TARGET_HOST:/tmp/" >/dev/null 2>&1 || die "scp 失败"
|
||
rm -f /tmp/keji-server.tgz
|
||
"${SSH[@]}" "cd $REMOTE_DIR && tar -xzf /tmp/keji-server.tgz && rm -f /tmp/keji-server.tgz" >/dev/null 2>&1 \
|
||
|| die "远端解压失败"
|
||
ok "server 源码落位"
|
||
rebuild_web
|
||
verify_site_up
|
||
}
|
||
deploy_admin() {
|
||
TARGET_CURRENT=admin
|
||
build_admin
|
||
transfer "admin/dist" "keji-admin.tgz"
|
||
verify_admin_hash
|
||
}
|
||
deploy_sdk() {
|
||
TARGET_CURRENT=sdk
|
||
sdk_tests
|
||
build_sdk
|
||
transfer "sdk/dist" "keji-sdk.tgz"
|
||
verify_sdk_bid
|
||
}
|
||
|
||
# ---- 编排(all 按契约依赖序: server → admin → sdk)----
|
||
preflight
|
||
case "$DEPLOY_TARGET" in
|
||
server) deploy_server ;;
|
||
admin) deploy_admin ;;
|
||
sdk) deploy_sdk ;;
|
||
all) deploy_server; deploy_admin; deploy_sdk ;;
|
||
esac
|
||
log "===== 部署完成 target=$DEPLOY_TARGET git=$GIT_HASH 用时 $SECONDS 秒 ====="
|