diff --git a/.gitignore b/.gitignore index 1895ef6..ff50077 100644 --- a/.gitignore +++ b/.gitignore @@ -53,4 +53,7 @@ license.lic licenses.db licenses.db-wal licenses.db-shm -授权台账.csv \ No newline at end of file +授权台账.csv +.trae +.ruff_cache +.pytest_cache \ No newline at end of file diff --git a/deploy/deploy.sh b/deploy/deploy.sh new file mode 100644 index 0000000..1459081 --- /dev/null +++ b/deploy/deploy.sh @@ -0,0 +1,1183 @@ +#!/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 "$@" diff --git a/test.png b/test.png deleted file mode 100644 index 9e9527b..0000000 Binary files a/test.png and /dev/null differ