432 lines
14 KiB
Bash
Executable File
432 lines
14 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# monitor-ai 部署脚本
|
||
#
|
||
# 用法:
|
||
# bash deploy-monitor.sh # 本地模式(默认)
|
||
# bash deploy-monitor.sh --dev # 开发模式(本地构建)
|
||
# bash deploy-monitor.sh --remote --host <IP> # 远程部署到指定服务器
|
||
#
|
||
# 首次部署自动:
|
||
# 1. 生成 OIDC_CLIENT_SECRET (openssl rand -hex 32)
|
||
# 2. 生成 LOCALADMIN_PASSWORD (openssl rand -hex 16)
|
||
# 3. 生成 pbkdf2 密码哈希(通过 Authelia 容器)
|
||
# 4. 更新 nginx 配置
|
||
#
|
||
# 支持系统:
|
||
# - macOS (本地): zsh + bash 兼容模式
|
||
# - Ubuntu/Debian (服务器): dash + bash
|
||
# - Rocky Linux/CentOS/RHEL (服务器): bash
|
||
|
||
# ============================================================
|
||
# 颜色和日志
|
||
# ============================================================
|
||
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; CYAN='\033[0;36m'; RED='\033[0;31m'; NC='\033[0m'
|
||
log() { printf "${GREEN}[✓]${NC} %s\n" "$1"; }
|
||
warn() { printf "${YELLOW}[!]${NC} %s\n" "$1"; }
|
||
info() { printf "${CYAN}[i]${NC} %s\n" "$1"; }
|
||
error() { printf "${RED}[✗]${NC} %s\n" "$1"; }
|
||
timing(){ printf " ${CYAN}⏱${NC} %s\n" "$1"; }
|
||
|
||
# ============================================================
|
||
# 检测 macOS 和 Linux sed 差异
|
||
# ============================================================
|
||
is_mac() {
|
||
[[ "$(uname)" == "Darwin" ]]
|
||
}
|
||
|
||
SED_I_BACKUP='-i'
|
||
if is_mac; then
|
||
SED_I_BACKUP='-i ""'
|
||
fi
|
||
|
||
# ============================================================
|
||
# 解析参数
|
||
# ============================================================
|
||
MODE="local"
|
||
HOST=""
|
||
while [[ $# -gt 0 ]]; do
|
||
case "$1" in
|
||
--remote)
|
||
MODE="remote"
|
||
info "模式: 远程部署"
|
||
shift
|
||
;;
|
||
--host)
|
||
HOST="$2"
|
||
info "目标主机: $HOST"
|
||
shift 2
|
||
;;
|
||
--dev)
|
||
MODE="dev"
|
||
info "模式: 开发构建"
|
||
shift
|
||
;;
|
||
*)
|
||
warn "未知参数: $1"
|
||
shift
|
||
;;
|
||
esac
|
||
done
|
||
|
||
# ============================================================
|
||
# 路径配置
|
||
# ============================================================
|
||
LOCAL_DIR="/Users/niuniu/programs/docker"
|
||
MONITOR_DIR="$LOCAL_DIR/monitor-ai"
|
||
SHARED_DIR="$LOCAL_DIR/shared"
|
||
REMOTE_DIR="/root/docker/monitor-ai"
|
||
CONTAINER="monitor-ai"
|
||
SSH_TARGET="txjp"
|
||
SNAPSHOT_FILE="/tmp/.snapshot.monitor.md5"
|
||
|
||
# 远程模式需要 host 参数
|
||
if [[ "$MODE" == "remote" && -z "$HOST" ]]; then
|
||
error "远程部署需要指定 --host <IP>"
|
||
echo " 用法: bash deploy-monitor.sh --remote --host 43.133.38.210"
|
||
exit 1
|
||
fi
|
||
|
||
# ============================================================
|
||
# 打包源码
|
||
# ============================================================
|
||
package_source() {
|
||
log "打包源码..."
|
||
local pack_start
|
||
pack_start=$(date +%s)
|
||
|
||
COPYFILE_DISABLE=1 tar czf /tmp/monitor-ai.tar.gz \
|
||
-C "$LOCAL_DIR" \
|
||
--exclude='node_modules' --exclude='.next' --exclude='out' \
|
||
--exclude='data' --exclude='docs' \
|
||
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
|
||
--exclude='.git' --exclude='._*' \
|
||
shared/ monitor-ai/ || {
|
||
error "打包失败"
|
||
return 1
|
||
}
|
||
|
||
local pack_end pkg_size pack_dur
|
||
pack_end=$(date +%s)
|
||
pack_dur=$((pack_end - pack_start))
|
||
pkg_size=$(du -h /tmp/monitor-ai.tar.gz 2>/dev/null | cut -f1 || echo "未知")
|
||
timing "打包耗时: ${pack_dur}s (${pkg_size})"
|
||
}
|
||
|
||
# ============================================================
|
||
# 首次部署:生成密钥和配置
|
||
# ============================================================
|
||
first_deploy_setup() {
|
||
local target="$1" # SSH target or "local"
|
||
local ssh_cmd=""
|
||
if [[ "$target" != "local" ]]; then
|
||
ssh_cmd="ssh $target"
|
||
fi
|
||
|
||
local run
|
||
if [[ -n "$ssh_cmd" ]]; then
|
||
run="$ssh_cmd"
|
||
else
|
||
run=""
|
||
fi
|
||
|
||
info "首次部署检查..."
|
||
|
||
# 检查 .env 是否存在
|
||
local env_exists
|
||
if [[ -n "$run" ]]; then
|
||
env_exists=$($run "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null)
|
||
else
|
||
env_exists=$(test -f "$MONITOR_DIR/.env" && echo yes || echo no)
|
||
fi
|
||
|
||
if [[ "$env_exists" == "yes" ]]; then
|
||
info "已存在 .env,跳过密钥生成"
|
||
return 0
|
||
fi
|
||
|
||
log "首次部署:生成密钥和配置..."
|
||
|
||
local OIDC_SECRET LOCALADMIN_PASS JWT_SECRET PBKDF2_HASH
|
||
OIDC_SECRET=$(openssl rand -hex 32)
|
||
LOCALADMIN_PASS=$(openssl rand -hex 16)
|
||
JWT_SECRET=$(openssl rand -hex 32)
|
||
|
||
# 生成 pbkdf2 密码哈希(通过 Authelia 容器或本地 openssl)
|
||
if [[ -n "$run" ]]; then
|
||
PBKDF2_HASH=$($run "docker exec authelia authelia crypto hash generate pbkdf2 --password '$LOCALADMIN_PASS' 2>/dev/null | grep 'Digest:' | awk '{print \$2}'" 2>/dev/null)
|
||
fi
|
||
|
||
# 如果 Authelia 不可用,使用 openssl 生成备用哈希
|
||
if [[ -z "$PBKDF2_HASH" ]]; then
|
||
warn "Authelia 不可用,使用 openssl 生成密码哈希"
|
||
PBKDF2_HASH=$(echo -n "$LOCALADMIN_PASS" | openssl dgst -sha256 -binary | openssl base64)
|
||
PBKDF2_HASH="pbkdf2:sha256:600000\$$(openssl rand -hex 16)\$$PBKDF2_HASH"
|
||
fi
|
||
|
||
# 写入 .env 文件
|
||
local env_content
|
||
env_content="DATABASE_PATH=/app/data/monitor.db
|
||
MONITOR_MODE=local
|
||
AUTHELIA_URL=https://sso.tlyq.ai
|
||
OIDC_CLIENT_ID=monitor-oidc
|
||
OIDC_CLIENT_SECRET=$OIDC_SECRET
|
||
OIDC_REDIRECT_URI=https://monitor.tlyq.ai/api/auth/callback
|
||
JWT_SECRET=$JWT_SECRET
|
||
COOKIE_DOMAIN=.tlyq.ai
|
||
LDAP_URL=ldap://ldap-ai:3890
|
||
LOCALADMIN_PASSWORD=$LOCALADMIN_PASS
|
||
NODE_ENV=production
|
||
NODE_TLS_REJECT_UNAUTHORIZED=0"
|
||
|
||
if [[ -n "$run" ]]; then
|
||
$run "cat > $REMOTE_DIR/.env << 'ENVEOF'
|
||
$env_content
|
||
ENVEOF"
|
||
else
|
||
echo "$env_content" > "$MONITOR_DIR/.env"
|
||
fi
|
||
|
||
log "密钥已生成"
|
||
info " OIDC_CLIENT_SECRET: $OIDC_SECRET"
|
||
info " LOCALADMIN_PASSWORD: $LOCALADMIN_PASS"
|
||
warn "请妥善保管以上密钥,.env 文件不会上传到版本控制"
|
||
}
|
||
|
||
# ============================================================
|
||
# 更新 nginx 配置
|
||
# ============================================================
|
||
update_nginx() {
|
||
local target="$1"
|
||
local nginx_conf="$LOCAL_DIR/nginx-proxy-ai/conf.d/monitor-ai.conf"
|
||
|
||
if [[ ! -f "$nginx_conf" ]]; then
|
||
warn "nginx 配置文件不存在: $nginx_conf"
|
||
return 0
|
||
fi
|
||
|
||
if [[ "$target" == "local" ]]; then
|
||
return 0
|
||
fi
|
||
|
||
log "更新 nginx 配置..."
|
||
scp "$nginx_conf" "$target:$REMOTE_DIR/../nginx-proxy-ai/conf.d/monitor-ai.conf" 2>/dev/null || true
|
||
ssh "$target" "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" && log "nginx 已重载" || warn "nginx 重载失败"
|
||
}
|
||
|
||
# ============================================================
|
||
# 服务器构建
|
||
# ============================================================
|
||
build_on_server() {
|
||
local target="$1"
|
||
|
||
# 1. 计算源码快照
|
||
log "检查源码是否有变化..."
|
||
local local_md5
|
||
local_md5=$(find "$MONITOR_DIR" "$SHARED_DIR" \
|
||
-not -path '*/node_modules/*' \
|
||
-not -path '*/.next/*' \
|
||
-not -path '*/out/*' \
|
||
-not -path '*/data/*' \
|
||
-not -path '*/docs/*' \
|
||
-not -path '*/.env*' \
|
||
-not -path '*/.git/*' \
|
||
-not -name '._*' \
|
||
-type f \
|
||
-exec md5 -q {} \; 2>/dev/null \
|
||
| sort \
|
||
| md5 -q)
|
||
echo " 源码快照: ${local_md5}"
|
||
|
||
local prev_md5
|
||
prev_md5=$(ssh "$target" "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "")
|
||
|
||
if [[ -z "$prev_md5" ]]; then
|
||
info "首次部署,执行完整构建"
|
||
elif [[ "$prev_md5" == "$local_md5" ]]; then
|
||
info "源码无变化,跳过构建,仅重启容器"
|
||
ssh "$target" "cd $REMOTE_DIR && docker compose down && docker compose up -d"
|
||
log "容器已重建"
|
||
return 0
|
||
else
|
||
info "检测到源码变化,执行增量构建"
|
||
fi
|
||
|
||
# 2. 打包源码
|
||
package_source
|
||
|
||
# 3. 上传
|
||
log "上传源码包..."
|
||
scp /tmp/monitor-ai.tar.gz "$target:/tmp/monitor-ai.tar.gz" || {
|
||
error "上传失败"
|
||
return 1
|
||
}
|
||
|
||
# 4. 解压源码到 /tmp,然后 rsync 到目标目录
|
||
log "解压源码并准备依赖..."
|
||
ssh "$target" "\
|
||
rm -rf /tmp/deploy_monitor && mkdir -p /tmp/deploy_monitor && \
|
||
tar xzf /tmp/monitor-ai.tar.gz -C /tmp/deploy_monitor 2>/dev/null || true && \
|
||
rsync -a --delete \
|
||
--exclude='node_modules' --exclude='.next' --exclude='data' --exclude='docs' \
|
||
--exclude='.env' --exclude='.env.local' --exclude='.env.development' --exclude='.env.production' \
|
||
--exclude='._*' \
|
||
/tmp/deploy_monitor/monitor-ai/ $REMOTE_DIR/ && \
|
||
rsync -a --delete \
|
||
--exclude='node_modules' --exclude='.git' \
|
||
/tmp/deploy_monitor/shared/ /root/docker/shared/ && \
|
||
rm -rf /tmp/deploy_monitor"
|
||
|
||
# 5. 首次部署:上传 docker-compose.yml 和 Dockerfile
|
||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d"
|
||
|
||
# 如果 host 上没有 node_modules,从容器内复制一份
|
||
ssh "$target" "if [ ! -d '${REMOTE_DIR}/node_modules' ]; then
|
||
echo ' 首次:复制容器内 node_modules 到主机...'
|
||
docker cp ${CONTAINER}:/app/node_modules ${REMOTE_DIR}/node_modules
|
||
echo ' 完成'
|
||
fi"
|
||
|
||
# 6. 安装可能新增的依赖
|
||
log "安装新增依赖..."
|
||
ssh "$target" "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true"
|
||
|
||
# 7. 清理可能被上传的本地环境变量文件
|
||
ssh "$target" "rm -f $REMOTE_DIR/.env.local $REMOTE_DIR/.env.development $REMOTE_DIR/.env.production 2>/dev/null; echo '已清理本地环境文件'"
|
||
|
||
# 8. 服务器上执行 npm run build
|
||
log "服务器上执行 npm run build..."
|
||
local build_start build_end build_dur
|
||
build_start=$(date +%s)
|
||
ssh "$target" "cd ${REMOTE_DIR} && npm run build 2>&1" | \
|
||
grep -vE "^(info|warn|npm warn|audited|packages|funding|vulnerability|npm notice|New major)" | \
|
||
tail -15
|
||
local build_exit_code=${PIPESTATUS[0]}
|
||
build_end=$(date +%s)
|
||
build_dur=$((build_end - build_start))
|
||
timing "构建耗时: ${build_dur}s"
|
||
|
||
if [[ "$build_exit_code" -ne 0 ]]; then
|
||
warn "构建可能有警告,请检查上面的输出"
|
||
fi
|
||
|
||
# 9. 重建容器
|
||
log "重建容器..."
|
||
ssh "$target" "cd $REMOTE_DIR && docker compose up -d && docker compose restart"
|
||
|
||
# 10. 清理构建缓存
|
||
log "清理构建缓存..."
|
||
ssh "$target" "docker image prune -f 2>/dev/null || true"
|
||
|
||
local disk_usage
|
||
disk_usage=$(ssh "$target" "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50")
|
||
if [ "$disk_usage" -gt 95 ]; then
|
||
warn "磁盘使用率 ${disk_usage}%,执行紧急清理"
|
||
ssh "$target" "docker builder prune -f 2>/dev/null || true"
|
||
elif [ "$disk_usage" -gt 85 ]; then
|
||
info "磁盘使用率 ${disk_usage}%,执行激进清理(6小时)"
|
||
ssh "$target" "docker builder prune -f --filter 'until=6h' 2>/dev/null || true"
|
||
elif [ "$disk_usage" -gt 70 ]; then
|
||
info "磁盘使用率 ${disk_usage}%,执行正常清理(24小时)"
|
||
ssh "$target" "docker builder prune -f --filter 'until=24h' 2>/dev/null || true"
|
||
else
|
||
info "磁盘使用率 ${disk_usage}%,执行保守清理(48小时)"
|
||
ssh "$target" "docker builder prune -f --filter 'until=48h' 2>/dev/null || true"
|
||
fi
|
||
|
||
# 11. 保存快照
|
||
ssh "$target" "echo '$local_md5' > ${SNAPSHOT_FILE}"
|
||
}
|
||
|
||
# ============================================================
|
||
# 开发模式(本地构建)
|
||
# ============================================================
|
||
build_dev() {
|
||
log "本地开发模式构建..."
|
||
local build_start build_end build_dur
|
||
build_start=$(date +%s)
|
||
|
||
cd "$MONITOR_DIR" || { error "无法进入 $MONITOR_DIR"; exit 1; }
|
||
npm run build 2>&1 | tail -15
|
||
local build_exit_code=${PIPESTATUS[0]}
|
||
build_end=$(date +%s)
|
||
build_dur=$((build_end - build_start))
|
||
timing "构建耗时: ${build_dur}s"
|
||
|
||
if [[ "$build_exit_code" -ne 0 ]]; then
|
||
error "构建失败"
|
||
exit 1
|
||
fi
|
||
|
||
log "本地构建完成"
|
||
}
|
||
|
||
# ============================================================
|
||
# 执行部署
|
||
# ============================================================
|
||
echo ""
|
||
printf "${CYAN}=========================================${NC}\n"
|
||
printf "${CYAN} monitor-ai 部署${NC}\n"
|
||
printf "${CYAN}=========================================${NC}\n"
|
||
echo ""
|
||
|
||
BUILD_START=$(date +%s)
|
||
|
||
case "$MODE" in
|
||
local)
|
||
# 本地模式:部署到 txjp 服务器(默认行为)
|
||
log "部署目标: $MONITOR_DIR → txjp:$REMOTE_DIR"
|
||
echo ""
|
||
|
||
# 首次部署检查
|
||
first_deploy_setup "$SSH_TARGET"
|
||
|
||
# 构建
|
||
build_on_server "$SSH_TARGET"
|
||
|
||
# 更新 nginx
|
||
update_nginx "$SSH_TARGET"
|
||
;;
|
||
|
||
remote)
|
||
# 远程模式:部署到指定服务器
|
||
log "部署目标: $MONITOR_DIR → $HOST:$REMOTE_DIR"
|
||
echo ""
|
||
|
||
# 首次部署检查
|
||
first_deploy_setup "$HOST"
|
||
|
||
# 构建
|
||
build_on_server "$HOST"
|
||
|
||
# 更新 nginx
|
||
update_nginx "$HOST"
|
||
;;
|
||
|
||
dev)
|
||
# 开发模式:仅本地构建
|
||
build_dev
|
||
;;
|
||
esac
|
||
|
||
# ============================================================
|
||
# 验证
|
||
# ============================================================
|
||
echo ""
|
||
log "验证部署..."
|
||
BUILD_END=$(date +%s)
|
||
TOTAL_DUR=$((BUILD_END - BUILD_START))
|
||
|
||
if [[ "$MODE" == "dev" ]]; then
|
||
log "开发构建完成,总耗时: ${TOTAL_DUR}s"
|
||
exit 0
|
||
fi
|
||
|
||
STATUS=$(ssh "$SSH_TARGET" "curl -s -o /dev/null -w '%{http_code}' -k 'https://monitor.tlyq.ai/api/health' 2>/dev/null" 2>/dev/null || echo "???")
|
||
if [[ "$STATUS" == "200" ]]; then
|
||
log "部署成功!总耗时: ${TOTAL_DUR}s | 访问 https://monitor.tlyq.ai"
|
||
exit 0
|
||
else
|
||
warn "返回状态码: $STATUS,请检查"
|
||
exit 1
|
||
fi
|