Files
chongming-core/deploy/deploy.sh
T

1184 lines
42 KiB
Bash
Raw Normal View History

2026-09-23 10:12:50 +08:00
#!/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 "$@"