#!/usr/bin/env bash # monitor-ai 部署脚本 # # 用法: # bash deploy-monitor.sh # 部署到 txjp 服务器(默认) # bash deploy-monitor.sh --dev # 开发模式(本地构建) # # 首次部署自动: # 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="remote" while [[ $# -gt 0 ]]; do case "$1" in --dev) MODE="dev" info "模式: 开发构建" shift ;; *) warn "未知参数: $1" shift ;; esac done # ============================================================ # 路径配置(对齐 deploy-ai.sh,使用 SSH 别名 txjp) # ============================================================ 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" # ============================================================ # 打包源码 # ============================================================ 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() { info "首次部署检查..." local env_exists env_exists=$(ssh txjp "test -f $REMOTE_DIR/.env && echo yes || echo no" 2>/dev/null) 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 容器) PBKDF2_HASH=$(ssh txjp "docker exec authelia authelia crypto hash generate pbkdf2 --password '$LOCALADMIN_PASS' 2>/dev/null | grep 'Digest:' | awk '{print \$2}'" 2>/dev/null) # 如果 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 文件 ssh txjp "cat > $REMOTE_DIR/.env << 'ENVEOF' 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 ENVEOF" log "密钥已生成" info " OIDC_CLIENT_SECRET: $OIDC_SECRET" info " LOCALADMIN_PASSWORD: $LOCALADMIN_PASS" warn "请妥善保管以上密钥,.env 文件不会上传到版本控制" } # ============================================================ # 更新 nginx 配置 # ============================================================ update_nginx() { 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 log "更新 nginx 配置..." scp "$nginx_conf" txjp:/root/docker/nginx-proxy-ai/conf.d/monitor-ai.conf 2>/dev/null || true ssh txjp "docker exec nginx-ai nginx -t && docker exec nginx-ai nginx -s reload" && log "nginx 已重载" || warn "nginx 重载失败" } # ============================================================ # 服务器构建 # ============================================================ build_on_server() { # 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 txjp "cat ${SNAPSHOT_FILE} 2>/dev/null" 2>/dev/null || echo "") if [[ -z "$prev_md5" ]]; then info "首次部署,执行完整构建" elif [[ "$prev_md5" == "$local_md5" ]]; then info "源码无变化,跳过构建,仅重启容器" ssh txjp "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 txjp:/tmp/monitor-ai.tar.gz || { error "上传失败" return 1 } # 4. 解压源码到 /tmp,然后 rsync 到目标目录 log "解压源码并准备依赖..." ssh txjp "\ 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/ && \ ln -sf /root/docker/shared $REMOTE_DIR/shared && \ rm -rf /tmp/deploy_monitor" # 5. 首次部署:上传 docker-compose.yml 和 Dockerfile ssh txjp "cd $REMOTE_DIR && docker compose up -d" # 如果 host 上没有 node_modules,从容器内复制一份 ssh txjp "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 txjp "cd ${REMOTE_DIR} && npm install --prefer-offline 2>&1 | tail -5 || true" # 7. 清理可能被上传的本地环境变量文件 ssh txjp "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 txjp "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 txjp "cd $REMOTE_DIR && docker compose up -d && docker compose restart" # 10. 清理构建缓存 log "清理构建缓存..." ssh txjp "docker image prune -f 2>/dev/null || true" local disk_usage disk_usage=$(ssh txjp "df / --output=pcent | tail -1 | tr -d ' %'" 2>/dev/null || echo "50") if [ "$disk_usage" -gt 95 ]; then warn "磁盘使用率 ${disk_usage}%,执行紧急清理" ssh txjp "docker builder prune -f 2>/dev/null || true" elif [ "$disk_usage" -gt 85 ]; then info "磁盘使用率 ${disk_usage}%,执行激进清理(6小时)" ssh txjp "docker builder prune -f --filter 'until=6h' 2>/dev/null || true" elif [ "$disk_usage" -gt 70 ]; then info "磁盘使用率 ${disk_usage}%,执行正常清理(24小时)" ssh txjp "docker builder prune -f --filter 'until=24h' 2>/dev/null || true" else info "磁盘使用率 ${disk_usage}%,执行保守清理(48小时)" ssh txjp "docker builder prune -f --filter 'until=48h' 2>/dev/null || true" fi # 11. 保存快照 ssh txjp "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 remote) # 远程模式:部署到 txjp 服务器 log "部署目标: $MONITOR_DIR → txjp:$REMOTE_DIR" echo "" # 首次部署检查 first_deploy_setup # 构建 build_on_server # 更新 nginx update_nginx ;; 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 txjp "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