Files

333 lines
14 KiB
Bash
Executable File

#!/usr/bin/env bash
# =============================================================================
# clover 升级脚本(VPS 宿主机)
#
# ./scripts/upgrade.sh # 拉代码 + 拉镜像 + 重建面板 + 重启 + 健康检查
# ./scripts/upgrade.sh --no-pull # 跳过 git pull(只更新镜像)
#
# 数据安全:SQLite 迁移在面板启动时自动执行(幂等);升级前可选自动备份。
# =============================================================================
set -euo pipefail
cd "$(dirname "$0")/.."
NO_PULL=0
[ "${1:-}" = "--no-pull" ] && NO_PULL=1
log() { printf '\033[1;32m[upgrade]\033[0m %s\n' "$*"; }
warn() { printf '\033[1;33m[upgrade]\033[0m %s\n' "$*"; }
UPGRADE_TMP=""
cleanup_upgrade_tmp() {
[ -z "$UPGRADE_TMP" ] || rm -rf -- "$UPGRADE_TMP"
}
trap cleanup_upgrade_tmp EXIT
update_from_archive() {
local repo="${CLOVER_REPO:-Vincentkeio/clover-mail}"
local ref="${CLOVER_REF:-main}"
local tmp src item deploy_item name url revision
UPGRADE_TMP="$(mktemp -d)"
tmp="$UPGRADE_TMP"
# Resolve once, then download that exact commit so version checks describe
# the files installed even if the branch advances during the download.
local auth_args=()
[ -z "${CLOVER_TOKEN:-}" ] || auth_args=(-H "Authorization: Bearer ${CLOVER_TOKEN}")
curl -fsSL --max-time 30 "${auth_args[@]}" \
"https://api.github.com/repos/${repo}/commits/${ref}" -o "${tmp}/revision.json"
revision="$(sed -nE '/"sha"[[:space:]]*:/ { s/.*"sha"[[:space:]]*:[[:space:]]*"([0-9a-f]{40})".*/\1/p; q; }' "${tmp}/revision.json")"
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || { echo '无法确认源码版本;当前服务未重建。' >&2; return 1; }
url="https://codeload.github.com/${repo}/tar.gz/${revision}"
if [ -n "${CLOVER_TOKEN:-}" ]; then
curl -fsSL --max-time 180 -H "Authorization: Bearer ${CLOVER_TOKEN}" \
"https://api.github.com/repos/${repo}/tarball/${revision}" -o "${tmp}/clover.tar.gz" \
|| { echo "代码下载失败(请检查仓库、分支和 CLOVER_TOKEN);当前服务未重建。" >&2; return 1; }
else
curl -fsSL --max-time 120 "$url" -o "${tmp}/clover.tar.gz" \
|| { echo "代码下载失败(请检查网络、仓库和分支);当前服务未重建。" >&2; return 1; }
fi
mkdir -p "${tmp}/source"
tar -xzf "${tmp}/clover.tar.gz" -C "${tmp}/source"
src="$(find "${tmp}/source" -mindepth 1 -maxdepth 1 -type d -print -quit)"
[ -n "$src" ] && [ -f "$src/compose.yaml" ] && [ -f "$src/scripts/upgrade.sh" ] \
&& [ -f "$src/scripts/maintenance.sh" ] \
|| { echo "下载的源码包不完整;当前服务未重建。" >&2; return 1; }
log "非 Git 安装 → 使用 GitHub ${repo}@${ref} 更新源码"
shopt -s dotglob nullglob
for item in "$src"/*; do
name="$(basename "$item")"
case "$name" in
.env|.env.bak.*|.env.before-*|backups) continue ;;
.env.*)
if [ "$name" != .env.example ]; then continue; fi
rm -f "$name"
cp -a "$item" .
;;
deploy)
mkdir -p deploy
for deploy_item in "$src/deploy"/*; do
name="$(basename "$deploy_item")"
[ "$name" = acme ] && continue
rm -rf "deploy/$name"
cp -a "$deploy_item" deploy/
done
;;
*)
rm -rf "$name"
cp -a "$item" .
;;
esac
done
shopt -u dotglob nullglob
[ -z "$revision" ] || printf '%s\n' "$revision" > .clover-source-revision
rm -rf -- "$UPGRADE_TMP"
UPGRADE_TMP=""
log "源码更新完成(.env、备份、deploy/acme 证书目录和 Docker 数据卷均保留)"
}
if [ -f .env ]; then
# shellcheck disable=SC1091
set -a; . ./.env; set +a
fi
# Manual upgrade entry point; bootstrap.sh handles install/overwrite choices separately.
port_in_use() {
ss -ltn 2>/dev/null | awk '{print $4}' | grep -qE "[:.]${1}$"
}
set_env() {
if grep -q "^$1=" .env; then
sed -i "s|^$1=.*|$1=$2|" .env
else
printf '%s=%s\n' "$1" "$2" >> .env
fi
}
if [ -f .env ] && [ "${CLOVER_SHARED_CADDY:-0}" != "1" ] \
&& { port_in_use 80 || port_in_use 443; } \
&& command -v python3 >/dev/null 2>&1; then
host_caddy="$(python3 scripts/host-caddy.py detect 2>/dev/null || true)"
if [ -n "$host_caddy" ]; then
CLOVER_HOST_CADDY_CONFIG="${host_caddy%%|*}"
CLOVER_HOST_CADDY_BIN="${host_caddy#*|}"
: "${CLOVER_HTTP_PORT:=18080}"
: "${CLOVER_HTTPS_PORT:=18443}"
[ "$CLOVER_HTTP_PORT" != 80 ] || CLOVER_HTTP_PORT=18080
[ "$CLOVER_HTTPS_PORT" != 443 ] || CLOVER_HTTPS_PORT=18443
CLOVER_SHARED_CADDY=1
CLOVER_SHARED_NGINX=0
CLOVER_CADDY_SCHEME=http
CLOVER_CADDY_HTTP_PUBLISH="127.0.0.1:${CLOVER_HTTP_PORT}"
CLOVER_CADDY_HTTPS_PUBLISH="127.0.0.1:${CLOVER_HTTPS_PORT}"
CLOVER_HOME="$(pwd)"
CLOVER_TLS_DIRECTIVE=
log "检测到宿主机 Caddy,修复共享入口配置(保留现有站点)"
set_env CLOVER_HTTP_PORT "$CLOVER_HTTP_PORT"
set_env CLOVER_HTTPS_PORT "$CLOVER_HTTPS_PORT"
set_env CLOVER_SHARED_CADDY "$CLOVER_SHARED_CADDY"
set_env CLOVER_SHARED_NGINX "$CLOVER_SHARED_NGINX"
set_env CLOVER_HOST_CADDY_CONFIG "$CLOVER_HOST_CADDY_CONFIG"
set_env CLOVER_HOST_CADDY_BIN "$CLOVER_HOST_CADDY_BIN"
set_env CLOVER_CADDY_SCHEME "$CLOVER_CADDY_SCHEME"
set_env CLOVER_CADDY_HTTP_PUBLISH "$CLOVER_CADDY_HTTP_PUBLISH"
set_env CLOVER_CADDY_HTTPS_PUBLISH "$CLOVER_CADDY_HTTPS_PUBLISH"
set_env CLOVER_HOME "$CLOVER_HOME"
set_env CLOVER_TLS_DIRECTIVE "$CLOVER_TLS_DIRECTIVE"
chmod 600 .env
fi
fi
if { port_in_use 80 || port_in_use 443; } && command -v systemctl >/dev/null 2>&1 \
&& systemctl is-active --quiet nginx; then
nginx_80=0; nginx_443=0
ss -H -ltnp 'sport = :80' 2>/dev/null | grep -q nginx && nginx_80=1 || true
ss -H -ltnp 'sport = :443' 2>/dev/null | grep -q nginx && nginx_443=1 || true
if { ! port_in_use 80 || [ "$nginx_80" -eq 1 ]; } \
&& { ! port_in_use 443 || [ "$nginx_443" -eq 1 ]; }; then
log "检测到宿主机 nginx,修复共享入口配置(Caddy 内部监听;证书签发会短暂停 nginx)"
: "${CLOVER_HTTP_PORT:=18080}"
: "${CLOVER_HTTPS_PORT:=18443}"
[ "$CLOVER_HTTP_PORT" != 80 ] || CLOVER_HTTP_PORT=18080
[ "$CLOVER_HTTPS_PORT" != 443 ] || CLOVER_HTTPS_PORT=18443
CLOVER_SHARED_NGINX=1
CLOVER_CADDY_SCHEME=http
CLOVER_CADDY_HTTP_PUBLISH="127.0.0.1:${CLOVER_HTTP_PORT}"
CLOVER_CADDY_HTTPS_PUBLISH="127.0.0.1:${CLOVER_HTTPS_PORT}"
CLOVER_HOME="$(pwd)"
CLOVER_TLS_DIRECTIVE=
set_env CLOVER_HTTP_PORT "$CLOVER_HTTP_PORT"
set_env CLOVER_HTTPS_PORT "$CLOVER_HTTPS_PORT"
set_env CLOVER_SHARED_NGINX "$CLOVER_SHARED_NGINX"
set_env CLOVER_CADDY_SCHEME "$CLOVER_CADDY_SCHEME"
set_env CLOVER_CADDY_HTTP_PUBLISH "$CLOVER_CADDY_HTTP_PUBLISH"
set_env CLOVER_CADDY_HTTPS_PUBLISH "$CLOVER_CADDY_HTTPS_PUBLISH"
set_env CLOVER_HOME "$CLOVER_HOME"
set_env CLOVER_TLS_DIRECTIVE "$CLOVER_TLS_DIRECTIVE"
chmod 600 .env
fi
fi
# 1) 升级前备份(有脚本就备份,失败不阻断但明确告警)
if [ -f scripts/backup.sh ] && [ -n "${RECONCILE_TOKEN:-}" ]; then
log "升级前备份…"
if ! bash scripts/backup.sh; then
warn "备份失败!继续升级前请确认可接受(Ctrl+C 中止)"
sleep 5
fi
else
warn "跳过备份(缺少 RECONCILE_TOKEN 或 backup.sh 不可执行)"
fi
# 2) 代码
if [ "${NO_PULL}" -eq 0 ]; then
if [ -d .git ]; then
if ! git diff --quiet || ! git diff --cached --quiet; then
STASH_LABEL="clover-upgrade-before-update-$(date +%Y%m%d-%H%M%S)"
log "将服务器本地代码改动保存到 Git stash:${STASH_LABEL}"
git stash push -m "${STASH_LABEL}"
fi
log "拉取仓库代码…"
git pull --rebase || {
echo "代码更新失败;本地代码改动仍保留在 Git stash 中。请检查 git status 与 git stash list 后重试。" >&2
exit 1
}
git rev-parse HEAD > .clover-source-revision
else
update_from_archive
fi
fi
# The maintenance entry point is mounted into the dashboard's docker:cli job.
# Keep it in the same source refresh as this upgrade script so the next job uses
# the matching implementation instead of a stale host copy.
[ -f scripts/maintenance.sh ] || {
echo "源码更新不完整:缺少 scripts/maintenance.sh;当前服务未重建。" >&2
exit 1
}
log "维护脚本已同步:scripts/maintenance.sh"
# A previous version may not have installed the `clover` command yet.
if [ -x scripts/install-cli.sh ]; then
bash scripts/install-cli.sh "$(pwd)" || warn "快捷命令安装未完成;服务升级继续,可稍后手动运行 bash scripts/install-cli.sh"
fi
bash scripts/ensure-roundcube-env.sh
export ROUNDCUBE_SSO_SECRET="$(sed -n 's/^ROUNDCUBE_SSO_SECRET=//p' .env)"
# 3) 拉取正式发布镜像并重启面板。
# 安装目录可能带有 panel/ 源码(例如 bootstrap 解压的 GitHub 源码包),
# 普通升级仍必须使用 GHCR 发布镜像,避免在 VPS 上本地 npm ci 耗尽磁盘。
COMPOSE_FILES=(-f compose.yaml)
PANEL_PUBLISH_REVISION=""
wait_for_panel_publish() {
local revision repo payload count status conclusion attempt commit_payload
revision="$(cat .clover-source-revision 2>/dev/null || true)"
[[ "$revision" =~ ^[0-9a-f]{40}$ ]] || return 0
repo="${CLOVER_REPO:-Vincentkeio/clover-mail}"
local auth_args=()
[ -z "${CLOVER_TOKEN:-}" ] || auth_args=(-H "Authorization: Bearer ${CLOVER_TOKEN}")
for attempt in $(seq 1 36); do
payload="$(curl -fsSL --max-time 15 "${auth_args[@]}" \
"https://api.github.com/repos/${repo}/actions/workflows/publish-panel.yml/runs?head_sha=${revision}&per_page=1")" \
|| { echo '无法查询面板镜像发布状态;为避免使用旧镜像,更新已停止。' >&2; return 1; }
count="$(printf '%s' "$payload" | grep -oE '"total_count"[[:space:]]*:[[:space:]]*[0-9]+' | head -n 1 | grep -oE '[0-9]+$' || true)"
[ -n "$count" ] || { echo '无法解析面板镜像发布状态;更新已停止。' >&2; return 1; }
if [ "$count" = 0 ]; then
if [ "$attempt" -lt 4 ]; then
[ "$attempt" -eq 1 ] && log '正在确认对应的面板镜像构建任务…'
sleep 10
continue
fi
commit_payload="$(curl -fsSL --max-time 15 "${auth_args[@]}" \
"https://api.github.com/repos/${repo}/commits/${revision}")" \
|| { echo '无法确认本次源码变更范围;为避免使用旧镜像,更新已停止。' >&2; return 1; }
if printf '%s' "$commit_payload" | grep -qE '"filename"[[:space:]]*:[[:space:]]*"(panel/|\.github/workflows/publish-panel\.yml)'; then
echo '本次源码包含面板变更,但镜像构建任务尚未出现;更新已停止,请稍后重试。' >&2
return 1
fi
log "本次源码提交没有触发面板镜像发布,继续使用最新已发布镜像。"
return 0
fi
status="$(printf '%s' "$payload" | grep -oE '"status"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n 1 | sed -E 's/.*"([^"]+)"$/\1/' || true)"
conclusion="$(printf '%s' "$payload" | grep -oE '"conclusion"[[:space:]]*:[[:space:]]*"[^"]+"' | head -n 1 | sed -E 's/.*"([^"]+)"$/\1/' || true)"
if [ "$status" = completed ]; then
[ "$conclusion" = success ] || { echo "面板镜像发布失败(${conclusion:-未知状态});更新已停止。" >&2; return 1; }
PANEL_PUBLISH_REVISION="$revision"
return 0
fi
[ "$attempt" -eq 1 ] && log '面板镜像仍在发布,等待对应版本完成…'
sleep 10
done
echo '等待面板镜像发布超时;更新已停止,稍后重试。' >&2
return 1
}
if [ "${CLOVER_UPDATE_TO_LATEST:-0}" = 1 ]; then
set_env CLOVER_TAG latest
export CLOVER_TAG=latest
fi
wait_for_panel_publish
log "拉取 GHCR 面板镜像及邮件/Caddy 镜像…"
for attempt in $(seq 1 6); do
docker compose "${COMPOSE_FILES[@]}" pull panel || {
echo "面板镜像拉取失败;未重建或重启面板。" >&2
exit 1
}
[ -z "$PANEL_PUBLISH_REVISION" ] && break
panel_image="${CLOVER_PANEL_IMAGE:-ghcr.io/vincentkeio/clover-panel}:${CLOVER_TAG:-latest}"
image_revision="$(docker image inspect "$panel_image" --format '{{index .Config.Labels "org.opencontainers.image.revision"}}' 2>/dev/null || true)"
[ "$image_revision" = "$PANEL_PUBLISH_REVISION" ] && break
[ "$attempt" -eq 6 ] && { echo '镜像仓库仍返回旧版面板镜像;当前服务未重建,请稍后重试。' >&2; exit 1; }
log '镜像仓库尚未同步到本次源码版本,稍后重试拉取…'
sleep 10
done
docker compose "${COMPOSE_FILES[@]}" pull mail caddy roundcube || {
echo "发布镜像拉取失败;未重建或重启面板。请检查 GHCR 网络后重试。" >&2
exit 1
}
log "重启面板(使用已发布镜像,不在 VPS 本地构建)…"
docker compose "${COMPOSE_FILES[@]}" up -d --no-build panel
log "重启其余服务(应用新镜像)…"
docker compose build certificate-helper
docker compose up -d certificate-helper
docker compose "${COMPOSE_FILES[@]}" up -d --no-build
if [ "${CLOVER_SHARED_NGINX:-0}" = "1" ] || [ "${CLOVER_SHARED_CADDY:-0}" = "1" ] || [ "${CLOVER_CADDY_SCHEME:-https}" = "http" ]; then
if [ "${CLOVER_SHARED_CADDY:-0}" = "1" ]; then
log "配置宿主机 Caddy 反代并检查面板证书…"
else
log "申请/续签面板证书并启用 nginx 反代…"
fi
docker exec clover-certificate-helper python3 /app/certificates.py issue --force
fi
# 4) 健康检查(在面板容器内 fetch,不依赖 DNS/证书)
log "等待面板健康检查(最多 60s)…"
ok=0
for i in $(seq 1 30); do
if docker exec clover-panel node -e "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
ok=1; break
fi
sleep 2
done
if [ "${ok}" -eq 1 ]; then
# Refresh configuration/plugin directory mounts after archive replacement.
docker compose "${COMPOSE_FILES[@]}" up -d --no-deps --no-build --force-recreate roundcube
bash scripts/configure-roundcube.sh
# Archive updates can replace the mounted directory itself. Recreate the
# gateway so both Git and archive upgrades use the current host files.
docker compose "${COMPOSE_FILES[@]}" up -d --no-deps --no-build --force-recreate caddy
rm -f .clover-local-build
log "升级完成,面板与 Roundcube 免密连接已就绪。查看状态:docker compose ps"
log "迁移日志:docker logs clover-panel | grep migrations"
else
warn "面板健康检查未通过!查看日志:docker logs --tail 100 clover-panel"
exit 1
fi