Files
chongming-core/deploy/deploy.sh
T
2026-09-23 10:12:50 +08:00

1184 lines
42 KiB
Bash
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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 <<EOF
chongming 部署脚本 — 在 AI Box 上安装 / 升级 / 回滚 chongming 服务
用法:
bash deploy.sh [选项]
模式(默认 deploy):
--rollback 还原最近一次备份(停服 → 解包还原 → 重启 → 健康检查)
--list-backups 列出 $BACKUP_ROOT 下的备份
可选参数:
-d, --install-dir DIR 安装目录,默认 $INSTALL_DIR
-u, --user USER 运行用户,默认 $RUN_USER(不存在则创建为系统用户)
-g, --group GROUP 运行用户组,默认 $RUN_GROUP
-b, --bundle DIR 打包产物目录,默认自动探测 <脚本目录>/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" <<EOF
TIME=$(date '+%Y-%m-%d %H:%M:%S')
INSTALL_DIR=$INSTALL_DIR
RUN_USER=$RUN_USER
RUN_GROUP=$RUN_GROUP
SERVICE_NAME=$SERVICE_NAME
SERVICE_UNIT=$UNIT_DST
ENV_FILE=$ENV_FILE
HEALTH_PORTS=$HEALTH_PORTS
BUNDLE_DIR=$BUNDLE_DIR
PREV_SERVICE_STATE=${PREV_SERVICE_STATE:-unknown}
DEPLOY_SCRIPT=$SELF_PATH
EOF
else
printf '%s[dry-run]%s 写入 %s/manifest.txt\n' "$C_MAGENTA" "$C_RST" "$BACKUP_DIR"
fi
local size
size="$(du -sh "$BACKUP_DIR" 2>/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 <<EOF
# $SERVICE_NAME.service — 由 deploy.sh 生成(本文件的 mtime 即上次部署时间)
#
# 安装目录: $INSTALL_DIR
# 运行用户: $RUN_USER:$RUN_GROUP
# 重新部署: sudo bash $SELF_PATH
# 回滚: sudo bash $SELF_PATH --rollback
#
# 常用操作:
# systemctl status $SERVICE_NAME # 状态
# journalctl -u $SERVICE_NAME -f # 实时日志(应用自身日志另见 $INSTALL_DIR/logs/)
# systemctl restart $SERVICE_NAME # 重启(SIGTERM 优雅排空,最长等待 30s)
#
# 可选:$ENV_FILE 中写 APP_WORKERS=<N> 覆盖 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 "$@"