Files
HomeDash/deploy/zd.sh
HomeDash Bot a6b5513f88 docs+chore: 部署脚本入库、README 补零停机部署与部署物料清单
仓库此前缺少部署脚本,只能靠临时文件操作,新人无法复现。补齐:

- deploy/zd.sh:零停机部署(新容器先在对侧端口验证到底,含 /search 预热
  SearXNG;再同 inode 覆写门户上游 + nginx -t + reload;切后哨兵"不得为 502";
  失败自动回滚,旧容器保留为回滚点)。可用环境变量覆盖主机/路径/镜像。
- deploy/verify-ui.js:部署后浏览器验收(渲染开销、后台窄屏、收边两段式、
  被拒内嵌弹层、分组增删改与「历史访问」保护)。任一运行时异常即判失败。

README 新增:
- 「零停机部署」含**单文件 bind mount 不能用 sed -i** 的坑(换 inode → 容器读到
  旧值 → 全站 502,本项目真实踩过)与正确顺序(同 inode 覆写 → 断言容器看到 →
  nginx -t → reload)
- 「部署需要的全部自定义文件」清单表
- 「管理后台」补分组自由增删、历史分组保护、完整 API 表
- 「工作空间视图」补可内嵌性三态探测(转发 Cookie、WAF 403 误判)、被拒弹层、
  悬浮控件收边与"第一次交互只滑出"
2026-09-11 20:13:44 +08:00

167 lines
7.8 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env bash
# HomeDash 零停机部署
#
# 为什么需要它:直接 docker stop/rename/run 会有十几秒完全不可用,而且新容器
# nginx 先就绪、SearXNG 还在启动,这期间 /search 返回 502。本脚本先在对侧端口
# 把新容器验证到底(含 /search,顺带预热 SearXNG),再改门户 nginx 上游并
# reload(瞬时切换),最后才停旧容器并保留为回滚点。
#
# 用法:
# deploy/zd.sh <version> 例: deploy/zd.sh v29
#
# 环境变量(默认值即本项目生产环境,可覆盖):
# NAVI_HOST 目标机 ssh 地址,默认 admin@10.0.0.10
# NAVI_SSH_PASS 目标机密码(无 ssh key 时必填)
# NAVI_UPSTREAM_HOST 门户 nginx 里 dashy 域名对应的主机名,默认 dashy.example.com
# NAVI_PORTAL_CONF 门户 nginx 配置文件绝对路径
# NAVI_DATA_DIR 数据卷宿主机路径
# NAVI_BASE_IMAGE 构建基础镜像,默认 jianf-navi:latest
#
# 失败会自动回滚:切上游前失败只清掉新容器(老容器继续服务);切上游后失败
# 会把配置还原并 reload。每次改动门户配置前都会先备份。
set -euo pipefail
VER="${1:?用法: deploy/zd.sh <version>}"
TAG="homedash:ui-${VER}"
STAMP="$(date +%Y%m%d%H%M%S)"
HOST="${NAVI_HOST:-admin@10.0.0.10}"
SSH_PASS="${NAVI_SSH_PASS:-}"
UPSTREAM_HOST="${NAVI_UPSTREAM_HOST:-dashy.example.com}"
PORTAL_CONF="${NAVI_PORTAL_CONF:-/vol1/docker/portal/nginx/nginx.conf}"
DATA_DIR="${NAVI_DATA_DIR:-/vol1/docker/navi-data}"
BASE_IMAGE="${NAVI_BASE_IMAGE:-jianf-navi:latest}"
SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CTX=/tmp/homedash-ctx
TAR="/tmp/homedash-${VER}.tar.gz"
REMOTE_TAR="/tmp/homedash-${VER}-${STAMP}.tar.gz"
SSH_OPTS=(-o StrictHostKeyChecking=no)
if [ -n "$SSH_PASS" ]; then
SSH=(sshpass -p "$SSH_PASS" ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(sshpass -p "$SSH_PASS" scp -q "${SSH_OPTS[@]}")
else
SSH=(ssh "${SSH_OPTS[@]}" "$HOST")
SCP=(scp -q "${SSH_OPTS[@]}")
fi
echo "== 1/3 本地构建 ${TAG}(源码 ${SRC})=="
cd "$SRC"
node --check web/navi.js
(cd admin-server && gofmt -l . && go test ./... >/dev/null)
(cd history-server && gofmt -l . && go test ./... >/dev/null)
rm -rf "$CTX"; mkdir -p "$CTX/runtime" "$CTX/web/extension" "$CTX/admin"
(cd history-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/history-server" .)
(cd admin-server && CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o "$CTX/runtime/admin-server" .)
cp nginx.conf supervisord.conf searxng-settings.yml conf.example.yml "$CTX/"
cp admin/index.html "$CTX/admin/index.html"
cp web/extension/install.html "$CTX/web/extension/install.html"
cp web/navi.js web/navi.css web/patch-index.py "$CTX/web/"
cat > "$CTX/Dockerfile" <<EOF
FROM ${BASE_IMAGE}
COPY runtime/history-server /usr/local/bin/history-server
COPY runtime/admin-server /usr/local/bin/admin-server
COPY nginx.conf /etc/nginx/nginx.conf
COPY supervisord.conf /etc/supervisor/conf.d/supervisord.conf
COPY searxng-settings.yml /etc/searxng/settings.yml
COPY conf.example.yml /app/defaults/conf.yml
COPY admin/index.html /app/admin/index.html
COPY web/extension/install.html /app/dashy/extension/install.html
COPY web/navi.js /app/dashy/navi.js
COPY web/navi.css /app/dashy/navi.css
COPY web/patch-index.py /tmp/patch-index.py
RUN python3 /tmp/patch-index.py /app/dashy/index.html
EOF
DOCKER_BUILDKIT=0 docker build -q -t "$TAG" "$CTX"
docker save "$TAG" | gzip -1 > "$TAR"
echo " 镜像与包就绪: $(ls -sh "$TAR" | awk '{print $1}')"
echo "== 2/3 上传 =="
"${SCP[@]}" "$TAR" "$HOST:$REMOTE_TAR"
echo "== 3/3 远端零停机切换 =="
"${SSH[@]}" "TAG='$TAG' STAMP='$STAMP' REMOTE_TAR='$REMOTE_TAR' VER='$VER' \
PORTAL_CONF='$PORTAL_CONF' UPSTREAM_HOST='$UPSTREAM_HOST' DATA_DIR='$DATA_DIR' bash -s" <<'REMOTE'
set -euo pipefail
CUR_PORT=$(grep -oP "${UPSTREAM_HOST//./\\.}\\s+http://127\\.0\\.0\\.1:\\K[0-9]+" "$PORTAL_CONF" | head -1)
[ -n "$CUR_PORT" ] || { echo "!! 读不到门户上游端口" >&2; exit 1; }
if [ "$CUR_PORT" = "3003" ]; then NEW_PORT=3004; else NEW_PORT=3003; fi
echo " 上游端口 ${CUR_PORT} → 新端口 ${NEW_PORT}"
CONF_BAK="${PORTAL_CONF}.bak-${STAMP}"
cp -a "$PORTAL_CONF" "$CONF_BAK"
gzip -dc "$REMOTE_TAR" | docker load >/dev/null
docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \
| grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|DEBIAN_FRONTEND)=' > "/tmp/navi-${STAMP}.env"
flipped=0
on_err() {
rc=$?
echo "!! 失败(rc=$rc),回滚" >&2
if [ "$flipped" = "1" ]; then
cp -a "$CONF_BAK" "$PORTAL_CONF"
docker exec portal-nginx nginx -t && docker exec portal-nginx nginx -s reload || true
fi
docker rm -f navi-next >/dev/null 2>&1 || true
exit $rc
}
trap on_err ERR
docker rm -f navi-next >/dev/null 2>&1 || true
docker run -d --name navi-next --restart unless-stopped --network bridge \
-p "${NEW_PORT}:8080" --env-file "/tmp/navi-${STAMP}.env" \
-v "${DATA_DIR}:/app/data" "homedash:ui-${VER}" >/dev/null
w200() { for _ in $(seq 1 90); do [ "$(curl -s -o /dev/null -w '%{http_code}' "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; }
w200 "http://127.0.0.1:${NEW_PORT}/conf.yml"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
w200 "http://127.0.0.1:${NEW_PORT}/navi.js"
# /search 会真正跑一次搜索:既确认 SearXNG 可用,也顺带把它预热,
# 这样切过去之后用户不会撞上"nginx 就绪但 SearXNG 还在启动"的 502。
w200 "http://127.0.0.1:${NEW_PORT}/search?q=linux"
echo " 新容器全绿,开始切换上游"
sed "s#\\(${UPSTREAM_HOST//./\\.}\\s*http://127\\.0\\.0\\.1:\\)${CUR_PORT};#\\1${NEW_PORT};#" "$PORTAL_CONF" > /tmp/portal.conf.new
# 关键:必须同 inode 覆写(cat > 而不是 sed -i / mv)。
# 该文件是以单文件 bind mount 挂进 portal-nginx 的,且挂载是只读的;
# 用 sed -i / mv 会换新 inode,容器仍指向旧(孤儿)inode,reload 读到的还是旧端口
# → 上游被切走而新端口无人应答 → 全站 502(本项目真实踩过)。
cat /tmp/portal.conf.new > "$PORTAL_CONF"
grep -q "${UPSTREAM_HOST} *http://127.0.0.1:${NEW_PORT};" "$PORTAL_CONF" || { echo "!! 宿主机配置未改写" >&2; false; }
# 容器看不到新值时(历史上被 sed -i 换过 inode)只能重启它重新绑定,
# 这会断开所有域名约 1 秒,所以先走 reload 路径。
if ! docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf; then
echo " 容器未看到新配置(inode 错位)→ 重启 portal-nginx"
docker restart portal-nginx >/dev/null
sleep 4
fi
docker exec portal-nginx grep -q "127.0.0.1:${NEW_PORT};" /etc/nginx/nginx.conf \
|| { echo "!! 容器内配置仍是旧端口" >&2; false; }
docker exec portal-nginx nginx -t
docker exec portal-nginx nginx -s reload
sleep 2
flipped=1
# 切后哨兵:经门户访问不得是 502/503/504(未登录正常是 302)。
# 这是"上游切走但新端口无人应答"唯一能看到的现象。
CODE=$(curl -s -o /dev/null -w '%{http_code}' -H "Host: ${UPSTREAM_HOST}" http://127.0.0.1/conf.yml)
case "$CODE" in
502|503|504) echo "!! 门户访问返回 $CODE(上游不可达)" >&2; false ;;
esac
echo " 上游已切到 ${NEW_PORT}(门户探测 $CODE)"
w200 "http://127.0.0.1:${NEW_PORT}/admin"
docker stop navi >/dev/null
docker rename navi "navi-prev-${STAMP}"
docker rename navi-next navi
trap - ERR
for n in $(docker ps -a --filter name=navi-prev --format '{{.Names}}'); do
case "$n" in *"$STAMP"*) ;; *) docker rm -f "$n" >/dev/null;; esac
done
rm -f "$REMOTE_TAR"
echo " 配置备份=$CONF_BAK 回滚点=navi-prev-${STAMP}"
echo "DEPLOY_OK tag=homedash:ui-${VER} port=${NEW_PORT} health=$(docker inspect navi --format '{{if .State.Health}}{{.State.Health.Status}}{{else}}{{.State.Status}}{{end}}')"
REMOTE