#!/usr/bin/env bash # ============================================================================== # deploy.sh — chongming 部署 / 回滚脚本 # 目标平台:AI Box(Sophon,ARM64 Linux + systemd) # # 在设备上执行,非 root 会自动 sudo 提权: # bash deploy.sh # 全新安装 / 覆盖升级(默认保留站点数据与配置) # bash deploy.sh --dry-run # 预演,只打印不落盘 # bash deploy.sh --no-start # 只安装不启动 # bash deploy.sh --force-config --force-data # 连站点配置与运行时数据一起还原成打包版 # bash deploy.sh --rollback # 还原最近一次备份 # bash deploy.sh --list-backups # 列出所有备份 # bash deploy.sh --help # 完整参数说明 # # 目录约定(默认值,均可用参数覆盖): # 打包产物 <脚本目录>/chongming 安装目录 /data/chongming # 服务单元 /etc/systemd/system/chongming.service # 运行用户 linaro:linaro 环境变量 /etc/chongming.env # 备份目录 /data/chongming-deploy-backups(保留最近 3 份) # # 退出码:0 成功 / 1 通用错误 / 2 前置检查失败 / 3 健康检查失败 # ============================================================================== # --- CRLF 自愈 --------------------------------------------------------------- # 脚本在 Windows 上编辑/传输后可能带上 CR,届时 bash 会报 $'\r': command not found。 # 做法:用 tr 把自身复制一份去掉所有 CR,与原文比较,有差异才替换自身并重新执行。 # 全程只用 tr/cmp 这类按字节处理的工具,不受平台文本模式换行转换影响;无 CR 时零副作用。 # 用 `bash deploy.sh` 启动时这段也能生效(shebang 行带 CR 时无法由内核直接执行)。 if [ -z "${_CM_DEPLOY_REEXEC:-}" ]; then _CM_SELF="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/$(basename "$0")" _CM_TMP="${_CM_SELF}.lf.$$" if [ -f "$_CM_SELF" ] && tr -d '\r' < "$_CM_SELF" > "$_CM_TMP" 2>/dev/null && [ -s "$_CM_TMP" ]; then if ! cmp -s "$_CM_TMP" "$_CM_SELF"; then _CM_MODE="$(stat -c '%a' "$_CM_SELF" 2>/dev/null || true)" if mv -f "$_CM_TMP" "$_CM_SELF" 2>/dev/null; then if [ -n "$_CM_MODE" ]; then chmod "$_CM_MODE" "$_CM_SELF" 2>/dev/null || true; fi printf '[deploy] 检测到 CRLF 行尾,已转换为 LF,重新执行 ...\n' >&2 _CM_DEPLOY_REEXEC=1 exec bash "$_CM_SELF" "$@" fi rm -f "$_CM_TMP" 2>/dev/null || true printf '[deploy] 脚本含 CRLF 行尾且无法就地修复(目录只读?),请手动转换:\n' >&2 printf ' tr -d "\\r" < %s > /tmp/deploy.sh && bash /tmp/deploy.sh\n' "$0" >&2 exit 1 fi fi rm -f "$_CM_TMP" 2>/dev/null || true fi set -Eeuo pipefail # ============================================================================== # 默认值 # ============================================================================== SELF_PATH="$(cd "$(dirname "$0")" 2>/dev/null && pwd)/$(basename "$0")" SCRIPT_DIR="$(dirname "$SELF_PATH")" INSTALL_DIR="${INSTALL_DIR:-/data/chongming}" RUN_USER="${RUN_USER:-linaro}" RUN_GROUP="${RUN_GROUP:-linaro}" SERVICE_NAME="${SERVICE_NAME:-chongming}" ENV_FILE="${ENV_FILE:-/etc/chongming.env}" BACKUP_ROOT="${BACKUP_ROOT:-/data/chongming-deploy-backups}" KEEP_BACKUPS="${KEEP_BACKUPS:-3}" # HEALTH_PORTS="${HEALTH_PORTS:-8000,1883,7447}" HEALTH_PORTS="${HEALTH_PORTS:-8000,1883}" HEALTH_TIMEOUT="${HEALTH_TIMEOUT:-60}" EXTRA_GROUPS="${EXTRA_GROUPS:-input}" FFMPEG_BIN_DIR="/opt/sophon/sophon-ffmpeg-latest/bin" # 单元安装目录:可用环境变量覆盖(非常规布局或测试用) UNIT_DIR="${UNIT_DIR:-/etc/systemd/system}" UNIT_DST="$UNIT_DIR/$SERVICE_NAME.service" # 打包产物中必须存在的文件(缺任何一个都无法启动) REQUIRED_FILES=( "chongming" "config.toml" "static.svfs" "config/zenoh_config.json5" "resources/app.mbank" "resources/plugins.mbank" ) # 整体覆盖的目录/文件(应用资产,不承载站点状态;其余状态文件走 install_files 里的 # 设备优先策略:config/ config.toml data/ cache_data/ 已存在则不覆盖,logs/ 永不拷贝) OVERWRITE_DIRS=("resources") OVERWRITE_FILES=("static.svfs") # 运行期全局 HEALTH_PORTS_ARR=() PREV_SERVICE_STATE="" # ============================================================================== # 参数 # ============================================================================== MODE="deploy" # deploy | rollback | list-backups DRY_RUN=0 FORCE=0 FORCE_CONFIG=0 FORCE_DATA=0 DO_START=1 BUNDLE_ARG="" UNIT_ARG="" BUNDLE_DIR="" UNIT_SRC="" UNIT_FROM_EMBEDDED=0 usage() { cat </chongming -s, --service-file FILE systemd 单元文件,默认自动探测脚本目录下的 chongming.service --env-file FILE 可选环境变量文件,默认 $ENV_FILE --backup-root DIR 备份根目录,默认 $BACKUP_ROOT --keep-backups N 备份保留份数,默认 $KEEP_BACKUPS(更旧的自动删除;0 表示不清理) --health-ports LIST 健康检查端口(逗号分隔),默认 $HEALTH_PORTS --health-timeout N 健康检查超时秒数,默认 $HEALTH_TIMEOUT(0 表示跳过健康检查) --extra-groups LIST 运行用户追加的附加组(逗号分隔),默认 $EXTRA_GROUPS 例:input(GPIO IN2 / /dev/input/event1 需要) --force-config 用打包版覆盖 config/ 与 config.toml(默认保留设备上的站点配置) --force-data 用打包版覆盖 data/ 与 cache_data/(默认保留设备上的运行时数据) --force 跳过非致命前置检查(如二进制架构不匹配) --no-start 只安装不启动,跳过启动与健康检查 --dry-run 预演:所有写操作只打印不执行 -h, --help 显示本帮助 默认行为(重要): 升级时 config/、config.toml、data/、cache_data/ 采用「设备优先」策略——设备上已有的文件 一律不覆盖,打包版里新增的文件照常补充进来;logs/ 永不从打包版拷贝,只创建目录。 退出码: 0 成功 1 通用错误 2 前置检查失败 3 健康检查失败 常用命令: systemctl status $SERVICE_NAME # 服务状态 journalctl -u $SERVICE_NAME -f # 实时日志 tail -f $INSTALL_DIR/logs/app-*.log # 应用自身日志 示例: bash deploy.sh # 常规升级 bash deploy.sh --dry-run # 先预演一遍 bash deploy.sh -d /opt/chongming -u root # 换目录、换运行用户 bash deploy.sh --rollback # 出问题回滚 EOF } parse_args() { while [ $# -gt 0 ]; do case "$1" in -d|--install-dir) INSTALL_DIR="${2:?$1 需要参数}"; shift 2 ;; -d=*|--install-dir=*) INSTALL_DIR="${1#*=}"; shift ;; -u|--user) RUN_USER="${2:?$1 需要参数}"; shift 2 ;; -u=*|--user=*) RUN_USER="${1#*=}"; shift ;; -g|--group) RUN_GROUP="${2:?$1 需要参数}"; shift 2 ;; -g=*|--group=*) RUN_GROUP="${1#*=}"; shift ;; -b|--bundle) BUNDLE_ARG="${2:?$1 需要参数}"; shift 2 ;; -b=*|--bundle=*) BUNDLE_ARG="${1#*=}"; shift ;; -s|--service-file) UNIT_ARG="${2:?$1 需要参数}"; shift 2 ;; -s=*|--service-file=*) UNIT_ARG="${1#*=}"; shift ;; --env-file) ENV_FILE="${2:?$1 需要参数}"; shift 2 ;; --env-file=*) ENV_FILE="${1#*=}"; shift ;; --backup-root) BACKUP_ROOT="${2:?$1 需要参数}"; shift 2 ;; --backup-root=*) BACKUP_ROOT="${1#*=}"; shift ;; --keep-backups) KEEP_BACKUPS="${2:?$1 需要参数}"; shift 2 ;; --keep-backups=*) KEEP_BACKUPS="${1#*=}"; shift ;; --health-ports) HEALTH_PORTS="${2:?$1 需要参数}"; shift 2 ;; --health-ports=*) HEALTH_PORTS="${1#*=}"; shift ;; --health-timeout) HEALTH_TIMEOUT="${2:?$1 需要参数}"; shift 2 ;; --health-timeout=*) HEALTH_TIMEOUT="${1#*=}"; shift ;; --extra-groups) EXTRA_GROUPS="${2:?$1 需要参数}"; shift 2 ;; --extra-groups=*) EXTRA_GROUPS="${1#*=}"; shift ;; --service-name) SERVICE_NAME="${2:?$1 需要参数}"; UNIT_DST="$UNIT_DIR/$SERVICE_NAME.service"; shift 2 ;; --force-config) FORCE_CONFIG=1; shift ;; --force-data) FORCE_DATA=1; shift ;; --force) FORCE=1; shift ;; --no-start) DO_START=0; shift ;; --dry-run) DRY_RUN=1; shift ;; --rollback) MODE="rollback"; shift ;; --list-backups) MODE="list-backups"; shift ;; -h|--help) usage; exit 0 ;; *) printf '[deploy] 未知参数: %s(--help 查看用法)\n' "$1" >&2; exit 1 ;; esac done INSTALL_DIR="${INSTALL_DIR%/}" BACKUP_ROOT="${BACKUP_ROOT%/}" case "$INSTALL_DIR" in /*) ;; *) printf '[deploy] 安装目录必须是绝对路径: %s\n' "$INSTALL_DIR" >&2; exit 1 ;; esac if [ "$INSTALL_DIR" = "/" ] || [ -z "$INSTALL_DIR" ]; then printf '[deploy] 安装目录不能是根目录\n' >&2; exit 1 fi if [ "$UNIT_DST" = "$SERVICE_NAME" ]; then UNIT_DST="$UNIT_DIR/$SERVICE_NAME.service" fi if ! [ "$HEALTH_TIMEOUT" -ge 0 ] 2>/dev/null; then printf '[deploy] --health-timeout 需要是非负整数: %s\n' "$HEALTH_TIMEOUT" >&2; exit 1 fi if ! [ "$KEEP_BACKUPS" -ge 0 ] 2>/dev/null; then printf '[deploy] --keep-backups 需要是非负整数: %s\n' "$KEEP_BACKUPS" >&2; exit 1 fi } # ============================================================================== # 输出 # ============================================================================== if [ -t 1 ]; then C_RST=$'\033[0m'; C_BOLD=$'\033[1m'; C_DIM=$'\033[2m' C_RED=$'\033[31m'; C_GREEN=$'\033[32m'; C_YELLOW=$'\033[33m' C_CYAN=$'\033[36m'; C_MAGENTA=$'\033[35m' else C_RST=''; C_BOLD=''; C_DIM=''; C_RED=''; C_GREEN=''; C_YELLOW=''; C_CYAN=''; C_MAGENTA='' fi info() { printf '%s[deploy]%s %s\n' "$C_CYAN" "$C_RST" "$*"; } ok() { printf '%s[ ok ]%s %s\n' "$C_GREEN" "$C_RST" "$*"; } warn() { printf '%s[warn]%s %s\n' "$C_YELLOW" "$C_RST" "$*" >&2; } err() { printf '%s[fail]%s %s\n' "$C_RED" "$C_RST" "$*" >&2; } step() { printf '\n%s==> %s%s\n' "$C_BOLD" "$*" "$C_RST"; } die() { err "$@"; exit 1; } # 所有写操作统一走 run(),--dry-run 时只打印 run() { if [ "$DRY_RUN" = 1 ]; then printf '%s[dry-run]%s %s\n' "$C_MAGENTA" "$C_RST" "$(printf '%q ' "$@")" return 0 fi "$@" } SERVICE_STOPPED=0 on_error() { local rc="$1" printf '\n' err "部署中断(退出码 $rc)" if [ "$SERVICE_STOPPED" = 1 ]; then warn "$SERVICE_NAME 目前处于停止状态,恢复方式:" warn " 1) 还原上一版本: sudo bash $SELF_PATH --rollback" warn " 2) 用当前文件试启动: sudo systemctl start $SERVICE_NAME" fi exit "$rc" } # ============================================================================== # 探测与工具函数 # ============================================================================== # 从第 $2 个字段起取值(manifest 用) manifest_get() { local key="$1" file="$2" [ -f "$file" ] || return 1 awk -F= -v k="$key" '$1==k { sub("^" k "=", ""); print; exit }' "$file" } detect_bundle() { local cand for cand in "$BUNDLE_ARG" "$SCRIPT_DIR/chongming" "$SCRIPT_DIR/build" "$SCRIPT_DIR"; do [ -n "$cand" ] || continue [ -d "$cand" ] || continue if [ -f "$cand/chongming" ] && [ -f "$cand/config.toml" ]; then (cd "$cand" && pwd) return 0 fi done return 1 } detect_unit() { local cand for cand in "$UNIT_ARG" "$SCRIPT_DIR/$SERVICE_NAME.service" \ "$SCRIPT_DIR/deploy/$SERVICE_NAME.service" \ "$SCRIPT_DIR/../$SERVICE_NAME.service" \ "$SCRIPT_DIR/chongming.service"; do [ -n "$cand" ] || continue if [ -f "$cand" ]; then ( cd "$(dirname "$cand")" && printf '%s/%s\n' "$(pwd)" "$(basename "$cand")" ) return 0 fi done return 1 } # 内嵌兜底单元:仅在找不到 chongming.service 时使用 write_embedded_unit() { cat <<'EOF' [Unit] Description=Chongming Zenoh Gateway (FastAPI + Zenoh + MQTT) Wants=network-online.target After=network-online.target StartLimitIntervalSec=60 StartLimitBurst=5 [Service] Type=simple ExecStart=/data/chongming/chongming WorkingDirectory=/data/chongming User=linaro Group=linaro UMask=0027 EnvironmentFile=-/etc/chongming.env Environment=PYTHONUNBUFFERED=1 Environment=PATH=/opt/sophon/sophon-ffmpeg-latest/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin KillSignal=SIGTERM KillMode=control-group TimeoutStopSec=30 Restart=on-failure RestartSec=3 LimitNOFILE=65535 LimitNPROC=65535 PrivateTmp=true NoNewPrivileges=true [Install] WantedBy=multi-user.target EOF } # 二进制架构检查:ELF 头 e_machine,aarch64 = 0xB7 check_binary_arch() { local f="$1" magic machine magic="$(od -An -tx1 -j0 -N4 "$f" 2>/dev/null | tr -d ' \n')" if [ "$magic" != "7f454c46" ]; then warn "$f 不是 ELF 文件(magic=$magic)" return 1 fi machine="$(od -An -tx1 -j18 -N2 "$f" 2>/dev/null | tr -d ' \n')" case "$machine" in b700|00b7) return 0 ;; *) warn "$f 的 ELF e_machine=$machine,不是 aarch64" ; return 1 ;; esac } port_listening() { local port="$1" hex found="" [ -n "$port" ] || return 0 if command -v ss >/dev/null 2>&1; then found="$(ss -ltnH 2>/dev/null | awk -v p=":${port}$" '$4 ~ p { print $4; exit }' || true)" if [ -z "$found" ]; then found="$(ss -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p { print $4; exit }' || true)" fi fi if [ -z "$found" ] && command -v netstat >/dev/null 2>&1; then found="$(netstat -ltn 2>/dev/null | awk -v p=":${port}$" '$4 ~ p { print $4; exit }' || true)" fi if [ -n "$found" ]; then return 0; fi hex="$(printf '%04X' "$port")" found="$(awk -v h="$hex" 'NR>1 && $4=="0A" { n=split($2,a,":"); if (a[n]==h) { print $2; exit } }' /proc/net/tcp 2>/dev/null || true)" if [ -n "$found" ]; then return 0; fi return 1 } # 打印占用端口的进程(尽力而为,拿不到就留空) port_owner_desc() { local port="$1" out="" if command -v ss >/dev/null 2>&1; then out="$(ss -ltnp 2>/dev/null | awk -v p=":${port}$" '$4 ~ p { print $0; exit }' || true)" elif command -v netstat >/dev/null 2>&1; then out="$(netstat -ltnp 2>/dev/null | awk -v p=":${port}$" '$4 ~ p { print $0; exit }' || true)" fi printf '%s' "${out:-未知进程}" } unit_installed() { systemctl cat "$SERVICE_NAME.service" >/dev/null 2>&1; } svc_active() { systemctl is-active --quiet "$SERVICE_NAME.service"; } svc_failed() { systemctl is-failed --quiet "$SERVICE_NAME.service"; } svc_mainpid() { systemctl show -p MainPID --value "$SERVICE_NAME.service" 2>/dev/null || true; } snapshot_files() { # dir out_file local dir="$1" out="$2" if [ ! -d "$dir" ]; then : > "$out" return 0 fi ( cd "$dir" && find . -type f 2>/dev/null | LC_ALL=C sort ) > "$out" || : > "$out" } # 找到最近的已存在祖先目录(用于查磁盘空间) nearest_existing_dir() { local d="$1" while [ ! -d "$d" ] && [ "$d" != "/" ] && [ -n "$d" ]; do d="$(dirname "$d")" done printf '%s' "${d:-/}" } dir_avail_kb() { df -Pk "$1" 2>/dev/null | awk 'NR==2 { print $4 }' || printf '0' } dir_size_kb() { if [ -e "$1" ]; then du -sk "$1" 2>/dev/null | awk '{ print $1 }'; else printf '0'; fi } # 最大的一份备份占用(用于估算备份所需空间),拿不到估算值就返回 0 backup_estimate_kb() { local biggest biggest="$(du -sk "$BACKUP_ROOT"/*/ 2>/dev/null | sort -n | tail -1 | awk '{ print $1 }' || true)" printf '%s' "${biggest:-0}" } # ============================================================================== # 步骤:提权 # ============================================================================== require_root() { if [ "$(id -u)" -eq 0 ]; then return 0; fi if [ "$DRY_RUN" = 1 ]; then warn "当前非 root;--dry-run 只是预演,继续以当前身份检查" return 0 fi if command -v sudo >/dev/null 2>&1; then info "需要 root 权限,正在通过 sudo 重新执行 ..." if [ $# -gt 0 ]; then exec sudo -E bash "$SELF_PATH" "$@" fi exec sudo -E bash "$SELF_PATH" fi die "需要 root 权限,且系统没有 sudo。请用 root 身份执行:su -c 'bash $SELF_PATH'" } # ============================================================================== # 步骤:前置检查 # ============================================================================== preflight() { step "前置检查" local missing=() f command -v systemctl >/dev/null 2>&1 || { err "系统没有 systemctl,本脚本仅适用于 systemd 环境"; exit 2; } ok "systemd 可用" # 打包产物 if [ -z "$BUNDLE_DIR" ]; then err "找不到打包产物目录(应包含 chongming 与 config.toml)" err "已查找: ${BUNDLE_ARG:-<未指定>} $SCRIPT_DIR/chongming $SCRIPT_DIR/build $SCRIPT_DIR" err "用 --bundle <目录> 显式指定" exit 2 fi for f in "${REQUIRED_FILES[@]}"; do [ -e "$BUNDLE_DIR/$f" ] || missing+=("$f") done if [ ${#missing[@]} -gt 0 ]; then err "打包产物缺少必需文件($BUNDLE_DIR):" for f in "${missing[@]}"; do err " - $f"; done exit 2 fi ok "打包产物: $BUNDLE_DIR($(du -sh "$BUNDLE_DIR" 2>/dev/null | awk '{print $1}'))" if [ "$UNIT_FROM_EMBEDDED" = 1 ]; then warn "未找到 $SERVICE_NAME.service,将使用脚本内嵌的最小单元配置(缺少原注释与调优说明)" else ok "单元文件: $UNIT_SRC" fi # 二进制架构 if ! check_binary_arch "$BUNDLE_DIR/chongming"; then if [ "$FORCE" = 1 ]; then warn "已用 --force 忽略架构不匹配,仍继续" else err "打包产物的 CPU 架构与目标设备不符,请确认用的是 ARM64 构建产物(--force 可强制继续)" exit 2 fi else ok "二进制架构: aarch64" fi # 磁盘空间:安装 + 备份 local bundle_kb avail_kb need_kb bundle_kb="$(dir_size_kb "$BUNDLE_DIR")" avail_kb="$(dir_avail_kb "$(nearest_existing_dir "$INSTALL_DIR")")" need_kb=$(( bundle_kb + $(backup_estimate_kb) )) if [ "$avail_kb" -gt 0 ]; then if [ "$avail_kb" -lt "$need_kb" ]; then err "磁盘空间不足($(nearest_existing_dir "$INSTALL_DIR")):可用 $((avail_kb/1024)) MB,预计需要 $((need_kb/1024)) MB" exit 2 fi ok "磁盘空间: 可用 $((avail_kb/1024)) MB,预计需要 $((need_kb/1024)) MB" fi # /tmp:Nuitka onefile 每次启动都要解包,PrivateTmp 用的是 /tmp local tmp_avail tmp_avail="$(dir_avail_kb /tmp)" if [ "$tmp_avail" -gt 0 ] && [ "$tmp_avail" -lt 512000 ]; then warn "/tmp 可用空间仅 $((tmp_avail/1024)) MB。Nuitka onefile 启动时需在 /tmp 解包,空间不足会启动失败(可考虑单元里 PrivateTmp=false)" fi # ffmpeg if [ -d "$FFMPEG_BIN_DIR" ]; then ok "sophon-ffmpeg: $FFMPEG_BIN_DIR" else warn "$FFMPEG_BIN_DIR 不存在,RTSP 拉流可能因找不到 ffmpeg 而失败" fi # 运行用户 if id -u "$RUN_USER" >/dev/null 2>&1; then ok "运行用户: $RUN_USER:$(id -gn "$RUN_USER")" else info "运行用户 $RUN_USER 不存在,安装时会创建为系统用户" fi # 端口占用(停服前只看一眼,被外来进程占用才告警) local p if unit_installed; then for p in "${HEALTH_PORTS_ARR[@]}"; do if port_listening "$p" && ! svc_active; then warn "端口 $p 已被占用($(port_owner_desc "$p")),且 $SERVICE_NAME 当前未运行" fi done fi } # ============================================================================== # 步骤:停服 # ============================================================================== stop_service() { step "停止服务" if ! unit_installed; then info "服务尚未安装,跳过" return 0 fi PREV_SERVICE_STATE="$(systemctl is-active "$SERVICE_NAME.service" 2>/dev/null || true)" if ! svc_active; then info "$SERVICE_NAME 当前未运行(状态: ${PREV_SERVICE_STATE:-unknown}),跳过" return 0 fi info "正在停止 $SERVICE_NAME(最多等待 40s,单元 TimeoutStopSec=30)..." if [ "$DRY_RUN" = 1 ]; then printf '%s[dry-run]%s systemctl stop %s\n' "$C_MAGENTA" "$C_RST" "$SERVICE_NAME.service" SERVICE_STOPPED=1 return 0 fi SERVICE_STOPPED=1 systemctl stop "$SERVICE_NAME.service" || { err "systemctl stop 失败,请先手动确认服务状态:systemctl status $SERVICE_NAME" exit 1 } local waited=0 while svc_active && [ "$waited" -lt 20 ]; do sleep 1 waited=$((waited + 1)) done if svc_active; then err "$SERVICE_NAME 在 20s 内未停止(PID $(svc_mainpid))" exit 1 fi ok "服务已停止(等待 ${waited}s)" # 端口释放检查:内核回收监听套接字可能有短暂延迟,先给 10s 宽限;仍被占用才判定为残留 local p owner held="" grace=0 while [ "$grace" -lt 10 ]; do held="" for p in "${HEALTH_PORTS_ARR[@]}"; do port_listening "$p" && held="$held $p" done [ -z "$held" ] && break sleep 1 grace=$((grace + 1)) done if [ -n "$held" ]; then for p in $held; do owner="$(port_owner_desc "$p")" err "端口 $p 在服务停止 ${grace}s 后仍被占用: $owner" done err "可能有残留进程或其它服务在用该端口。请先处理,再重新执行部署。" err "如需恢复现场: sudo systemctl start $SERVICE_NAME" exit 1 fi ok "端口已释放: ${HEALTH_PORTS//,/ }" } # ============================================================================== # 步骤:备份 # ============================================================================== BACKUP_DIR="" do_backup() { step "备份现有版本" if [ ! -d "$INSTALL_DIR" ]; then info "$INSTALL_DIR 不存在(全新安装),无需备份" return 0 fi BACKUP_DIR="$BACKUP_ROOT/$(date +%Y%m%d-%H%M%S)" run mkdir -p "$BACKUP_DIR" local members=() m for m in chongming config.toml static.svfs config resources data cache_data; do [ -e "$INSTALL_DIR/$m" ] && members+=("$m") done if [ ${#members[@]} -gt 0 ]; then # 排除 logs 与 data/uploads:体积大且不承载站点状态 run tar -czf "$BACKUP_DIR/install.tar.gz" \ -C "$INSTALL_DIR" --exclude='logs' --exclude='data/uploads' \ "${members[@]}" fi if [ -f "$UNIT_DST" ]; then run cp -f "$UNIT_DST" "$BACKUP_DIR/$SERVICE_NAME.service" fi if [ -f "$ENV_FILE" ]; then run cp -f "$ENV_FILE" "$BACKUP_DIR/$(basename "$ENV_FILE")" fi # 清单:让 --rollback 在没带参数时也能还原到正确位置 if [ "$DRY_RUN" != 1 ]; then cat > "$BACKUP_DIR/manifest.txt" </dev/null | awk '{print $1}' || true)" ok "备份完成: $BACKUP_DIR(${size:-未知大小})" prune_backups } prune_backups() { [ "$KEEP_BACKUPS" -gt 0 ] || return 0 local olds=() d while IFS= read -r d; do [ -n "$d" ] && olds+=("$d") done < <(ls -1dt "$BACKUP_ROOT"/*/ 2>/dev/null | tail -n +$((KEEP_BACKUPS + 1)) || true) if [ ${#olds[@]} -eq 0 ]; then return 0; fi for d in "${olds[@]}"; do info "清理旧备份: $d" run rm -rf "$d" done } list_backups() { if [ ! -d "$BACKUP_ROOT" ]; then info "备份目录不存在: $BACKUP_ROOT" return 0 fi local d n=0 size mtime minstall while IFS= read -r d; do [ -n "$d" ] || continue n=$((n + 1)) size="$(du -sh "$d" 2>/dev/null | awk '{print $1}' || true)" printf ' %2d) %-44s %s\n' "$n" "$d" "${size:-?}" if [ -f "$d/manifest.txt" ]; then mtime="$(manifest_get TIME "$d/manifest.txt" || true)" minstall="$(manifest_get INSTALL_DIR "$d/manifest.txt" || true)" printf ' 时间=%s 安装目录=%s\n' "${mtime:-?}" "${minstall:-?}" fi done < <(ls -1dt "$BACKUP_ROOT"/*/ 2>/dev/null || true) if [ "$n" -eq 0 ]; then info "暂无备份"; fi } # ============================================================================== # 步骤:安装文件 # ============================================================================== CFG_ADDED=0 CFG_KEPT=0 DATA_ADDED=0 DATA_KEPT=0 # 同步站点状态目录 # merge:no-clobber,设备上已有的文件不动,打包版新增的文件补进来 # force:打包版覆盖同名文件(设备上多出来的文件不删除) # 结果写入 LAST_ADDED / LAST_KEPT LAST_ADDED=0 LAST_KEPT=0 sync_state_dir() { # src dst mode local src="$1" dst="$2" mode="$3" LAST_ADDED=0 LAST_KEPT=0 [ -d "$src" ] || return 0 if [ "$DRY_RUN" = 1 ]; then local flag="" [ "$mode" = "merge" ] && flag="n" printf '%s[dry-run]%s cp -a%s %s/. %s/\n' "$C_MAGENTA" "$C_RST" "$flag" "$src" "$dst" return 0 fi local before after before="$(mktemp)" after="$(mktemp)" snapshot_files "$dst" "$before" mkdir -p "$dst" if [ "$mode" = "merge" ]; then cp -an "$src/." "$dst/" else cp -a "$src/." "$dst/" fi snapshot_files "$dst" "$after" LAST_ADDED="$(comm -13 "$before" "$after" | wc -l | tr -d ' ')" LAST_KEPT="$(comm -12 "$before" "$after" | wc -l | tr -d ' ')" rm -f "$before" "$after" return 0 } install_files() { step "安装文件到 $INSTALL_DIR" run mkdir -p "$INSTALL_DIR" # 1) 二进制:先删后拷,避免正在运行的旧文件导致 ETXTBSY run rm -f "$INSTALL_DIR/chongming" run cp -f "$BUNDLE_DIR/chongming" "$INSTALL_DIR/chongming" run chmod 0755 "$INSTALL_DIR/chongming" # 2) 应用资产:整体覆盖 local d f for d in "${OVERWRITE_DIRS[@]}"; do if [ -d "$BUNDLE_DIR/$d" ]; then run rm -rf "$INSTALL_DIR/$d" run cp -a "$BUNDLE_DIR/$d" "$INSTALL_DIR/$d" fi done for f in "${OVERWRITE_FILES[@]}"; do if [ -e "$BUNDLE_DIR/$f" ]; then run cp -f "$BUNDLE_DIR/$f" "$INSTALL_DIR/$f" fi done # 3) 站点配置:默认设备优先 local cfg_mode="merge" if [ "$FORCE_CONFIG" = 1 ]; then cfg_mode="force" warn "--force-config:用打包版覆盖 config/ 与 config.toml(设备上多出来的文件不会被删除)" fi sync_state_dir "$BUNDLE_DIR/config" "$INSTALL_DIR/config" "$cfg_mode" CFG_ADDED="$LAST_ADDED" CFG_KEPT="$LAST_KEPT" if [ "$cfg_mode" = "force" ] || [ ! -f "$INSTALL_DIR/config.toml" ]; then if [ -e "$BUNDLE_DIR/config.toml" ]; then run cp -f "$BUNDLE_DIR/config.toml" "$INSTALL_DIR/config.toml" fi else info "保留设备上的 config.toml(站点配置);如需用打包版覆盖请加 --force-config" fi # 4) 运行时数据:默认设备优先 local data_mode="merge" if [ "$FORCE_DATA" = 1 ]; then data_mode="force" warn "--force-data:用打包版覆盖 data/ 与 cache_data/" fi for d in data cache_data; do sync_state_dir "$BUNDLE_DIR/$d" "$INSTALL_DIR/$d" "$data_mode" DATA_ADDED=$((DATA_ADDED + LAST_ADDED)) DATA_KEPT=$((DATA_KEPT + LAST_KEPT)) done # 5) 运行目录:只创建,不从打包版拷日志 run mkdir -p "$INSTALL_DIR/data/uploads" "$INSTALL_DIR/cache_data" "$INSTALL_DIR/logs" ok "文件已就位" # 6) 属主与权限 run chown -R "$RUN_USER:$RUN_GROUP" "$INSTALL_DIR" run chmod 0755 "$INSTALL_DIR/chongming" run chmod 0750 "$INSTALL_DIR" for d in data cache_data logs config resources; do if [ -d "$INSTALL_DIR/$d" ]; then run chmod 0750 "$INSTALL_DIR/$d" fi done ok "属主 $RUN_USER:$RUN_GROUP,目录权限 0750" } # ============================================================================== # 步骤:运行用户与附加组 # ============================================================================== setup_user() { step "运行用户与权限" if id -u "$RUN_USER" >/dev/null 2>&1; then local cur_group cur_group="$(id -gn "$RUN_USER")" if [ "$cur_group" != "$RUN_GROUP" ]; then warn "$RUN_USER 的主组是 $cur_group,与 --group 指定的 $RUN_GROUP 不同;单元文件仍按 $RUN_USER:$RUN_GROUP 启动(root 可跨组设置)" fi ok "运行用户已存在: $RUN_USER:$cur_group" else if getent group "$RUN_GROUP" >/dev/null 2>&1; then run useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin --gid "$RUN_GROUP" "$RUN_USER" else run useradd --system --home-dir "$INSTALL_DIR" --shell /usr/sbin/nologin --user-group "$RUN_USER" fi ok "已创建系统用户: $RUN_USER(home=$INSTALL_DIR,shell=nologin)" fi # 附加组(GPIO IN2 / /dev/input/event1 需要 input 组) local groups_arr=() g IFS=',' read -r -a groups_arr <<< "$EXTRA_GROUPS" for g in "${groups_arr[@]}"; do [ -n "$g" ] || continue if ! getent group "$g" >/dev/null 2>&1; then warn "附加组 $g 不存在,跳过" continue fi if id -nG "$RUN_USER" 2>/dev/null | tr ' ' '\n' | grep -qx "$g"; then info "$RUN_USER 已在 $g 组内" else run usermod -aG "$g" "$RUN_USER" ok "已将 $RUN_USER 加入 $g 组" fi done } # ============================================================================== # 步骤:安装 systemd 单元 # ============================================================================== render_unit() { # src out local src="$1" out="$2" { cat < 覆盖 worker 数。 # 注意:本文件由 deploy.sh 重写 ExecStart/WorkingDirectory/User/Group/EnvironmentFile, # 手改这几行会在下次部署时被覆盖,请改用命令行参数。 EOF awk -v d="$INSTALL_DIR" -v u="$RUN_USER" -v g="$RUN_GROUP" -v ef="$ENV_FILE" ' BEGIN { hdr = 1 } { if (hdr) { if ($0 ~ /^#/ || $0 ~ /^[[:space:]]*$/) next; hdr = 0 } } /^[[:space:]]*ExecStart=/ { print "ExecStart=" d "/chongming"; next } /^[[:space:]]*WorkingDirectory=/ { print "WorkingDirectory=" d; next } /^[[:space:]]*User=/ { print "User=" u; next } /^[[:space:]]*Group=/ { print "Group=" g; next } /^[[:space:]]*EnvironmentFile=/ { print "EnvironmentFile=-" ef; next } { print } ' "$src" | sed 's/\r$//' } > "$out" } install_unit() { step "安装 systemd 单元" local tmp template rendered_changed=0 tmp="$(mktemp)" template="$UNIT_SRC" if [ "$UNIT_FROM_EMBEDDED" = 1 ]; then template="$(mktemp)" write_embedded_unit > "$template" fi render_unit "$template" "$tmp" if [ "$UNIT_FROM_EMBEDDED" = 1 ]; then rm -f "$template"; fi if [ -f "$UNIT_DST" ] && cmp -s "$tmp" "$UNIT_DST"; then info "单元内容无变化,跳过写入" else if [ -f "$UNIT_DST" ]; then info "更新单元文件(旧文件已在备份目录中)" else info "写入单元文件 $UNIT_DST" fi if [ "$DRY_RUN" = 1 ]; then printf '%s[dry-run]%s install -m 0644 %s %s\n' "$C_MAGENTA" "$C_RST" "$tmp" "$UNIT_DST" else install -m 0644 "$tmp" "$UNIT_DST" fi rendered_changed=1 fi rm -f "$tmp" # 语法自检(尽力而为,仅告警) if [ "$DRY_RUN" != 1 ] && [ "$rendered_changed" = 1 ] && command -v systemd-analyze >/dev/null 2>&1; then if ! systemd-analyze verify "$UNIT_DST" >/dev/null 2>&1; then warn "systemd-analyze verify 对 $UNIT_DST 有告警,请留意(多为 EnvironmentFile/路径不存在等提示)" fi fi # 可选环境变量文件 if [ -f "$ENV_FILE" ]; then info "保留现有 $ENV_FILE" else if [ "$DRY_RUN" = 1 ]; then printf '%s[dry-run]%s 创建 %s\n' "$C_MAGENTA" "$C_RST" "$ENV_FILE" else cat > "$ENV_FILE" <<'EOF' # chongming 可选环境变量(由 deploy.sh 创建) # 取消注释并修改后执行 systemctl restart chongming 生效 # APP_WORKERS=4 EOF chmod 0644 "$ENV_FILE" fi ok "已创建 $ENV_FILE" fi run systemctl daemon-reload ok "systemd 已重新加载" } # ============================================================================== # 步骤:启动与健康检查 # ============================================================================== start_service() { step "启动服务" run systemctl enable --now "$SERVICE_NAME.service" ok "systemctl enable --now $SERVICE_NAME" } health_check() { step "健康检查(超时 ${HEALTH_TIMEOUT}s,端口 ${HEALTH_PORTS})" if [ "$DRY_RUN" = 1 ]; then info "--dry-run:跳过健康检查" return 0 fi if [ "$HEALTH_TIMEOUT" -eq 0 ]; then info "健康检查超时为 0,跳过" return 0 fi local deadline=$((SECONDS + HEALTH_TIMEOUT)) local last_pid="" stable=0 missing="" p while [ "$SECONDS" -lt "$deadline" ]; do if svc_failed; then err "$SERVICE_NAME 已进入 failed 状态(启动重试次数用尽)" dump_diagnostics exit 3 fi if svc_active; then missing="" for p in "${HEALTH_PORTS_ARR[@]}"; do port_listening "$p" || missing="$missing $p" done if [ -z "$missing" ]; then local pid pid="$(svc_mainpid)" if [ -n "$pid" ] && [ "$pid" = "$last_pid" ]; then stable=$((stable + 1)) else stable=1 last_pid="$pid" fi if [ "$stable" -ge 2 ]; then ok "服务运行中(PID $pid),端口全部就绪" return 0 fi info "服务已启动(PID ${pid:-?}),等待稳定性确认 ..." else info "等待端口就绪:${missing}" fi else info "等待服务进入 active ..." fi sleep 2 done err "健康检查超时(${HEALTH_TIMEOUT}s)" if [ -n "$missing" ]; then err "以下端口未监听:${missing}" for p in $missing; do # 端口明明没在监听却被别的东西占了是不可能的,只有监听中才值得报占用者 if port_listening "$p"; then err " 端口 $p 正被其它进程占用: $(port_owner_desc "$p")" fi done fi dump_diagnostics exit 3 } dump_diagnostics() { printf '\n' warn "---- systemctl status $SERVICE_NAME ----" systemctl status "$SERVICE_NAME.service" --no-pager -l 2>&1 | head -40 || true warn "---- journalctl -u $SERVICE_NAME(最后 80 行)----" journalctl -u "$SERVICE_NAME.service" --no-pager -n 80 2>&1 || true warn "---- 应用日志(最后 40 行)----" if [ -d "$INSTALL_DIR/logs" ]; then find "$INSTALL_DIR/logs" -maxdepth 1 -name '*.log' -type f -print0 2>/dev/null \ | xargs -0 -r tail -n 40 2>/dev/null | tail -n 80 || true fi printf '\n' warn "排查建议:" warn " - 配置问题: 检查 $INSTALL_DIR/config.toml 与 $INSTALL_DIR/config/workers/*/config.toml" warn " - 依赖缺失: ldconfig -p | grep -i zenoh;RTSP 相关先确认 $FFMPEG_BIN_DIR 存在" warn " - 还原上一版本: sudo bash $SELF_PATH --rollback" } # ============================================================================== # 模式:回滚 # ============================================================================== do_rollback() { step "回滚到最近一次备份" local latest latest="$(ls -1dt "$BACKUP_ROOT"/*/ 2>/dev/null | head -1 || true)" if [ -z "$latest" ]; then die "备份目录 $BACKUP_ROOT 下没有可用的备份" fi latest="${latest%/}" # 未显式指定参数时,按备份清单还原 if [ -f "$latest/manifest.txt" ]; then local m_install m_user m_group m_env m_svc m_install="$(manifest_get INSTALL_DIR "$latest/manifest.txt" || true)" m_user="$(manifest_get RUN_USER "$latest/manifest.txt" || true)" m_group="$(manifest_get RUN_GROUP "$latest/manifest.txt" || true)" m_env="$(manifest_get ENV_FILE "$latest/manifest.txt" || true)" m_svc="$(manifest_get SERVICE_NAME "$latest/manifest.txt" || true)" [ -n "$m_install" ] && INSTALL_DIR="$m_install" [ -n "$m_user" ] && RUN_USER="$m_user" [ -n "$m_group" ] && RUN_GROUP="$m_group" [ -n "$m_env" ] && ENV_FILE="$m_env" [ -n "$m_svc" ] && SERVICE_NAME="$m_svc" UNIT_DST="$UNIT_DIR/$SERVICE_NAME.service" info "按备份清单元数据还原" fi info "使用备份 : $latest" info "安装目录 : $INSTALL_DIR" info "运行用户 : $RUN_USER:$RUN_GROUP" if [ -f "$latest/install.tar.gz" ]; then info "内容 : $(tar -tzf "$latest/install.tar.gz" 2>/dev/null | awk -F/ '{print $1}' | sort -u | tr '\n' ' ' || true)" else die "备份中缺少 install.tar.gz: $latest" fi if [ "$DRY_RUN" = 1 ]; then info "--dry-run:以下操作不会真正执行" fi if unit_installed && svc_active; then SERVICE_STOPPED=1 run systemctl stop "$SERVICE_NAME.service" ok "服务已停止" fi run mkdir -p "$INSTALL_DIR" # 覆盖式解包:刻意不先清空目录,保证 logs/ 与 data/uploads/ 不丢 run tar -xzf "$latest/install.tar.gz" -C "$INSTALL_DIR" ok "文件已还原(覆盖同名文件;设备上多出来的新文件不会被删除)" if [ -f "$latest/$SERVICE_NAME.service" ]; then if [ "$DRY_RUN" = 1 ]; then printf '%s[dry-run]%s install -m 0644 %s %s\n' "$C_MAGENTA" "$C_RST" "$latest/$SERVICE_NAME.service" "$UNIT_DST" else install -m 0644 "$latest/$SERVICE_NAME.service" "$UNIT_DST" fi ok "单元文件已还原" fi if [ -f "$latest/$(basename "$ENV_FILE")" ]; then run cp -f "$latest/$(basename "$ENV_FILE")" "$ENV_FILE" ok "环境变量文件已还原" fi run chown -R "$RUN_USER:$RUN_GROUP" "$INSTALL_DIR" run systemctl daemon-reload run systemctl restart "$SERVICE_NAME.service" ok "服务已用旧版本重启" health_check printf '\n' ok "回滚完成:$SERVICE_NAME 已恢复到 $(basename "$latest") 时的版本" warn "注意:logs/ 与 data/uploads/ 保持回滚前的状态未被覆盖" } # ============================================================================== # 摘要 # ============================================================================== summary() { printf '\n%s================ 部署完成 ================%s\n' "$C_GREEN" "$C_RST" printf ' %-12s %s\n' "安装目录" "$INSTALL_DIR" printf ' %-12s %s\n' "运行用户" "$RUN_USER:$RUN_GROUP" printf ' %-12s %s\n' "服务单元" "$UNIT_DST" printf ' %-12s %s\n' "监听端口" "${HEALTH_PORTS//,/ }" if [ -n "$BACKUP_DIR" ]; then printf ' %-12s %s\n' "本次备份" "$BACKUP_DIR" fi if [ "$FORCE_CONFIG" = 0 ] && [ "$DRY_RUN" = 0 ]; then printf ' %-12s config 新增 %s 个 / 保留现场 %s 个文件\n' "配置" "$CFG_ADDED" "$CFG_KEPT" fi if [ "$FORCE_DATA" = 0 ] && [ "$DRY_RUN" = 0 ]; then printf ' %-12s data 新增 %s 个 / 保留现场 %s 个文件\n' "数据" "$DATA_ADDED" "$DATA_KEPT" fi printf '\n 查看状态 systemctl status %s\n' "$SERVICE_NAME" printf ' 实时日志 journalctl -u %s -f\n' "$SERVICE_NAME" printf ' 应用日志 tail -f %s/logs/app-*.log\n' "$INSTALL_DIR" printf ' 回滚命令 sudo bash %s --rollback\n' "$SELF_PATH" printf '%s==========================================%s\n' "$C_GREEN" "$C_RST" } # ============================================================================== # 主流程 # ============================================================================== main() { parse_args "$@" IFS=',' read -r -a HEALTH_PORTS_ARR <<< "$HEALTH_PORTS" if [ "$MODE" = "list-backups" ]; then require_root "$@" list_backups exit 0 fi if [ "$MODE" = "rollback" ]; then require_root "$@" trap 'on_error $?' ERR do_rollback exit 0 fi # 部署模式 require_root "$@" trap 'on_error $?' ERR if BUNDLE_DIR="$(detect_bundle)"; then : else BUNDLE_DIR="" fi if UNIT_SRC="$(detect_unit)"; then : else UNIT_SRC="" UNIT_FROM_EMBEDDED=1 fi # rollback 模式不需要打包产物,部署模式必须有 if [ -n "$BUNDLE_ARG" ] && [ -z "$BUNDLE_DIR" ]; then err "指定的打包目录无效: $BUNDLE_ARG" err "该目录需包含: ${REQUIRED_FILES[*]}" exit 2 fi if [ "$DRY_RUN" = 1 ]; then warn "== dry-run 模式:所有写操作只打印,不落盘 ==" fi preflight stop_service do_backup install_files setup_user install_unit if [ "$DO_START" = 1 ]; then start_service health_check SERVICE_STOPPED=0 summary else printf '\n' ok "安装完成(--no-start:未启动服务)" info "启动命令: sudo systemctl start $SERVICE_NAME" fi if [ "$DRY_RUN" = 1 ]; then printf '\n' warn "== dry-run 结束:以上命令均未执行 ==" fi } main "$@"