333 lines
14 KiB
Bash
Executable File
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
|