#!/usr/bin/env bash # shellcheck disable=SC2034 # 共用:路徑、載入 env、log helper。由其他腳本 source。 set -euo pipefail DEPLOY_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT_DIR="$(cd "$DEPLOY_DIR/.." && pwd)" BACKEND_DIR="$ROOT_DIR/apps/backend" WEB_DIR="$ROOT_DIR/apps/web" RUN_DIR="$DEPLOY_DIR/run" COMPOSE_FILE="$DEPLOY_DIR/docker-compose.infra.yml" PID_GATEWAY="$RUN_DIR/gateway.pid" PID_WEB="$RUN_DIR/web.pid" PID_WORKER="$RUN_DIR/worker.pid" LOG_GATEWAY="$RUN_DIR/gateway.log" LOG_WEB="$RUN_DIR/web.log" LOG_WORKER="$RUN_DIR/worker.log" mkdir -p "$RUN_DIR" log() { printf '[deploy] %s\n' "$*"; } err() { printf '[deploy] ERROR: %s\n' "$*" >&2; } die() { err "$*"; exit 1; } # 優先 .env.dev,其次 .env resolve_env_file() { if [[ -n "${ENV_FILE:-}" && -f "$ENV_FILE" ]]; then echo "$ENV_FILE" return fi if [[ -f "$DEPLOY_DIR/.env.dev" ]]; then echo "$DEPLOY_DIR/.env.dev" return fi if [[ -f "$DEPLOY_DIR/.env" ]]; then echo "$DEPLOY_DIR/.env" return fi return 1 } load_env() { local f if ! f="$(resolve_env_file)"; then die "找不到 env。請先:cp deploy/.env.example deploy/.env.dev 並填密鑰" fi log "env: $f" set -a # shellcheck disable=SC1090 source "$f" set +a # 別名正規化(給 gateway ApplyEnv / migrate) export MONGO_URI="${MONGO_URI:-${HAIXUN_MONGO_URI:-}}" if [[ -z "${MONGO_URL:-}" ]]; then if [[ -n "${MONGO_URI:-}" ]]; then export MONGO_URL="$( MONGO_URI="$MONGO_URI" MONGO_DATABASE="${MONGO_DATABASE:-haixun}" python3 -c " import os, re u = os.environ.get('MONGO_URI', '') db = os.environ.get('MONGO_DATABASE', 'haixun') if re.search(r'/\?', u): print(re.sub(r'/\?', f'/{db}?', u, count=1)) elif re.search(r'/' + re.escape(db) + r'(\?|$)', u): print(u) else: print(u.rstrip('/') + f'/{db}') " )" else local user="${MONGO_ROOT_USER:-haixun}" local pass="${MONGO_ROOT_PASSWORD:-}" local db="${MONGO_DATABASE:-haixun}" export MONGO_URI="mongodb://${user}:${pass}@127.0.0.1:27017/?authSource=admin" export MONGO_URL="mongodb://${user}:${pass}@127.0.0.1:27017/${db}?authSource=admin" fi fi export REDIS_PASSWORD="${REDIS_PASSWORD:-${HAIXUN_REDIS_PASSWORD:-}}" export HAIXUN_REDIS_PASSWORD="${HAIXUN_REDIS_PASSWORD:-$REDIS_PASSWORD}" export AUTH_ACCESS_SECRET="${AUTH_ACCESS_SECRET:-${HAIXUN_JWT_ACCESS_SECRET:-}}" export AUTH_REFRESH_SECRET="${AUTH_REFRESH_SECRET:-${HAIXUN_JWT_REFRESH_SECRET:-}}" export HAIXUN_STORAGE_S3_ACCESS_KEY="${HAIXUN_STORAGE_S3_ACCESS_KEY:-${MINIO_ROOT_USER:-haixun}}" export HAIXUN_STORAGE_S3_SECRET_KEY="${HAIXUN_STORAGE_S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-}}" export MINIO_ROOT_USER="${MINIO_ROOT_USER:-haixun}" export MINIO_ROOT_PASSWORD="${MINIO_ROOT_PASSWORD:-$HAIXUN_STORAGE_S3_SECRET_KEY}" export OBJECT_STORAGE_PUBLIC_BASE_URL="${OBJECT_STORAGE_PUBLIC_BASE_URL:-${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL:-}}" export HAIXUN_STORAGE_S3_PUBLIC_BASE_URL="${HAIXUN_STORAGE_S3_PUBLIC_BASE_URL:-$OBJECT_STORAGE_PUBLIC_BASE_URL}" export PUBLIC_WEB_BASE="${PUBLIC_WEB_BASE:-http://127.0.0.1:5173}" export GATEWAY_PORT="${GATEWAY_PORT:-8888}" export WEB_PORT="${WEB_PORT:-5173}" ENV_FILE_RESOLVED="$f" } compose() { local f f="$(resolve_env_file)" || die "no env" docker compose --env-file "$f" -f "$COMPOSE_FILE" "$@" } pid_alive() { local pid="${1:-}" [[ -n "$pid" ]] && kill -0 "$pid" 2>/dev/null } # 累積無法殺掉的 pid(給最後印 sudo 指令) STUCK_PIDS="" # 溫和 → 強制殺掉一個 pid(必要時試 sudo -n) kill_pid() { local pid="${1:-}" local label="${2:-pid}" [[ -z "$pid" || ! "$pid" =~ ^[0-9]+$ ]] && return 0 [[ "$pid" == "$$" || "$pid" == "$PPID" ]] && return 0 log "kill $label pid=$pid" # 一般使用者 if kill -TERM "$pid" 2>/dev/null; then sleep 0.35 kill -KILL "$pid" 2>/dev/null || true sleep 0.15 if ! kill -0 "$pid" 2>/dev/null; then return 0 fi fi # passwordless sudo if command -v sudo >/dev/null 2>&1; then if sudo -n kill -TERM "$pid" 2>/dev/null; then sleep 0.35 sudo -n kill -KILL "$pid" 2>/dev/null || true sleep 0.15 if ! sudo -n kill -0 "$pid" 2>/dev/null; then return 0 fi fi fi err "無法結束 $label pid=$pid(權限不足,多半是 root 起的)" STUCK_PIDS="${STUCK_PIDS} ${pid}" return 0 # 不讓 set -e 中斷;由呼叫端看 STUCK_PIDS / 埠 } # 找出 listen 在 port 上的 PID(ss / fuser / lsof / /proc inode) pids_on_port() { local port="$1" local out="" p p="$(ss -tlnp 2>/dev/null | sed -n "s/.*:${port} .*pid=\([0-9]*\).*/\1/p" | sort -u | tr '\n' ' ' || true)" out="$out $p" if command -v fuser >/dev/null 2>&1; then p="$( { fuser "${port}/tcp" 2>/dev/null || true; } | grep -oE '[0-9]+' | tr '\n' ' ' || true)" out="$out $p" fi if command -v lsof >/dev/null 2>&1; then p="$(lsof -tiTCP:"$port" -sTCP:LISTEN 2>/dev/null || true)" out="$out $p" fi p="$( PORT="$port" python3 - <<'PY' 2>/dev/null || true import os port = int(os.environ["PORT"]) inodes = set() for path in ("/proc/net/tcp", "/proc/net/tcp6"): try: lines = open(path).read().splitlines()[1:] except OSError: continue for line in lines: parts = line.split() if len(parts) < 10 or parts[3] != "0A": continue try: p = int(parts[1].rsplit(":", 1)[1], 16) except ValueError: continue if p == port: inodes.add(parts[9]) found = [] if inodes: for pid in os.listdir("/proc"): if not pid.isdigit(): continue fd_dir = f"/proc/{pid}/fd" try: names = os.listdir(fd_dir) except OSError: continue for name in names: try: target = os.readlink(f"{fd_dir}/{name}") except OSError: continue if target.startswith("socket:[") and target[8:-1] in inodes: found.append(pid) break print(" ".join(found)) PY )" out="$out $p" echo "$out" | tr -s '[:space:]' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true } kill_port() { local port="$1" local p for p in $(pids_on_port "$port"); do kill_pid "$p" "port:${port}" || true done return 0 } # 依 cmdline 關鍵字找 PID(不用 pgrep -f,避免誤殺本腳本) pids_by_cmdline_substr() { local a="${1:-}" b="${2:-}" [[ -z "$a" ]] && return 0 SUB_A="$a" SUB_B="$b" SELF="$$" python3 - <<'PY' 2>/dev/null || true import os a = os.environ.get("SUB_A", "") b = os.environ.get("SUB_B", "") self_pid = os.environ.get("SELF", "") out = [] for pid in os.listdir("/proc"): if not pid.isdigit() or pid == self_pid: continue try: raw = open(f"/proc/{pid}/cmdline", "rb").read() except OSError: continue cmd = raw.replace(b"\x00", b" ").decode("utf-8", "replace") if a not in cmd: continue if b and b not in cmd: continue if any(x in cmd for x in ("restart.sh", "stop.sh", "start.sh", "deploy/scripts")): continue out.append(pid) print(" ".join(out)) PY } port_in_use() { local port="$1" if ss -tln 2>/dev/null | grep -qE "[:.]${port}[[:space:]]"; then return 0 fi return 1 } wait_port_free() { local port="$1" local secs="${2:-8}" local i for i in $(seq 1 "$secs"); do if ! port_in_use "$port"; then return 0 fi sleep 0.5 done return 1 } print_stuck_hint() { local uniq # grep 無 match 時 exit 1 — 必須 || true,否則 set -o pipefail 會讓 stop 整段失敗 uniq="$(echo "${STUCK_PIDS:-}" | tr -s ' ' '\n' | grep -E '^[0-9]+$' | sort -u | tr '\n' ' ' || true)" uniq="$(echo "$uniq" | xargs 2>/dev/null || true)" if [[ -n "$uniq" ]]; then err "下列 process 殺不掉(多半是 root 啟動的):${uniq}" err "請執行: sudo kill -9 ${uniq}" err "然後再: make -C deploy restart" fi } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "需要指令:$1" }