From 816af97bb03dc95ee113bd19fd2374b04bc94f6e Mon Sep 17 00:00:00 2001 From: john Date: Tue, 21 Jul 2026 17:07:13 +0800 Subject: [PATCH] feat: add isolated read-only gray deployment --- docs/DEPLOY.md | 7 ++ scripts/.releaseignore-prod | 1 + scripts/deploy-gray-103.sh | 210 +++++++++++++++++++++++++++++++++ server/app.image-make.test.mjs | 29 +++++ server/app.mjs | 12 ++ 5 files changed, 259 insertions(+) create mode 100755 scripts/deploy-gray-103.sh diff --git a/docs/DEPLOY.md b/docs/DEPLOY.md index 779a807..4e88615 100644 --- a/docs/DEPLOY.md +++ b/docs/DEPLOY.md @@ -91,6 +91,11 @@ bash scripts/release-prod.sh --package-only # 候选包生成后,只读检查 103 灰度前置条件 bash scripts/check-gray-ready.sh --candidate-dir release-candidates/ +# 明确批准后,部署 103 loopback-only 灰度实例;不切换 live +bash scripts/deploy-gray-103.sh \ + --candidate-dir release-candidates/ \ + --yes + # 正式生产发布;必须在灰度通过并得到明确许可后执行 bash scripts/release-prod.sh ``` @@ -109,6 +114,8 @@ bash scripts/release-prod.sh 1. `check-gray-ready.sh` 默认只做后台候选包、103 当前健康状态、灰度端口和 image_make 版本的只读预检。 2. `--require-image-integration` 额外要求 Portal runtime-config 路由、image_make 配置 URL 和 worker 均已就绪。该条件涉及生产运行态,未经单独许可不得开启。 +3. `deploy-gray-103.sh` 只在 `127.0.0.1:15174/18085` 启动独立灰度实例,不替换 live,不重启 `5174/8085`,并设置 `ADM_READ_ONLY_MODE=1` 阻断管理后台与 Plaza API 写操作。 +4. 灰度页面默认只能通过 SSH 端口转发访问,不增加 105 nginx/Caddy 路由,也不产生公网入口。 `release-prod.sh` 没有“灰度即生产”的隐式行为:不带 `--package-only` 时就是正式 live 原子切换,必须等灰度验收通过后再执行。 diff --git a/scripts/.releaseignore-prod b/scripts/.releaseignore-prod index 40b252e..8f8c222 100644 --- a/scripts/.releaseignore-prod +++ b/scripts/.releaseignore-prod @@ -36,6 +36,7 @@ scripts/pro_restart.sh scripts/rsync_to_server.sh scripts/audit-103-state.sh scripts/check-gray-ready.sh +scripts/deploy-gray-103.sh scripts/gadm-nginx-105.conf scripts/gadm-nginx.conf.example scripts/gadm-lb.Caddyfile diff --git a/scripts/deploy-gray-103.sh b/scripts/deploy-gray-103.sh new file mode 100755 index 0000000..3845043 --- /dev/null +++ b/scripts/deploy-gray-103.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +HOST="${STUDIO_HOST:-10.10.0.2}" +GRAY_WEB_PORT="${GRAY_WEB_PORT:-15174}" +GRAY_API_PORT="${GRAY_API_PORT:-18085}" +REMOTE_GRAY_ROOT="${REMOTE_GRAY_ROOT:-/Users/john/Project/gray/memind_adm}" +CANDIDATE_DIR="" +AUTO_YES=0 + +usage() { + cat <<'EOF' +Usage: + bash scripts/deploy-gray-103.sh --candidate-dir DIR [--yes] + +Deploys a loopback-only, read-only memind_adm gray instance on 103. +It does not replace /Users/john/Project/memind_adm and does not restart live ports 5174/8085. + +Defaults: + gray web: 127.0.0.1:15174/ops/ + gray API: 127.0.0.1:18085 + +The --yes flag is mandatory because this writes a separate gray release to 103. +EOF +} + +while [[ $# -gt 0 ]]; do + case "$1" in + --candidate-dir) + shift + [[ $# -gt 0 ]] || { echo "--candidate-dir requires a directory" >&2; exit 2; } + CANDIDATE_DIR="$1" + ;; + --yes|-y) AUTO_YES=1 ;; + -h|--help) + usage + exit 0 + ;; + *) + echo "Unknown argument: $1" >&2 + usage >&2 + exit 2 + ;; + esac + shift +done + +[[ -n "${CANDIDATE_DIR}" ]] || { usage >&2; exit 2; } +if [[ "${CANDIDATE_DIR}" != /* ]]; then + CANDIDATE_DIR="${ROOT}/${CANDIDATE_DIR}" +fi +[[ -d "${CANDIDATE_DIR}" ]] || { echo "Candidate directory not found: ${CANDIDATE_DIR}" >&2; exit 1; } + +manifest="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.manifest.txt' -print -quit)" +sha_file="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.sha256' -print -quit)" +bundle="$(find "${CANDIDATE_DIR}" -maxdepth 1 -type f -name '*.tar.gz' -print -quit)" +[[ -n "${manifest}" && -n "${sha_file}" && -n "${bundle}" ]] || { + echo "Candidate bundle, manifest, or checksum file missing" >&2 + exit 1 +} + +release_id="$(sed -n 's/^release_id=//p' "${manifest}" | head -n 1)" +[[ "${release_id}" =~ ^[A-Za-z0-9._-]+$ ]] || { + echo "Invalid release_id in manifest: ${release_id}" >&2 + exit 1 +} + +bash "${ROOT}/scripts/check-gray-ready.sh" --candidate-dir "${CANDIDATE_DIR}" + +if [[ "${AUTO_YES}" -ne 1 ]]; then + echo + echo "Gray deployment is ready but was not started." + echo "Target: ${HOST} ${REMOTE_GRAY_ROOT}/releases/${release_id}" + echo "Ports: 127.0.0.1:${GRAY_WEB_PORT}, 127.0.0.1:${GRAY_API_PORT}" + echo "Re-run with --yes after explicit approval." + exit 2 +fi + +remote_incoming="${REMOTE_GRAY_ROOT}/incoming/${release_id}" +ssh -o BatchMode=yes -o ConnectTimeout=15 "${HOST}" "mkdir -p '${remote_incoming}'" +scp -q "${bundle}" "${manifest}" "${sha_file}" "${HOST}:${remote_incoming}/" + +ssh -o BatchMode=yes "${HOST}" \ + "RELEASE_ID='${release_id}' GRAY_WEB_PORT='${GRAY_WEB_PORT}' GRAY_API_PORT='${GRAY_API_PORT}' REMOTE_GRAY_ROOT='${REMOTE_GRAY_ROOT}' /bin/bash" <<'REMOTE' +set -euo pipefail + +LIVE_DIR=/Users/john/Project/memind_adm +INCOMING_DIR="${REMOTE_GRAY_ROOT}/incoming/${RELEASE_ID}" +RELEASE_DIR="${REMOTE_GRAY_ROOT}/releases/${RELEASE_ID}" +BUNDLE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.tar.gz" +MANIFEST="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.manifest.txt" +SHA_FILE="${INCOMING_DIR}/memind-adm-${RELEASE_ID}.sha256" + +stop_gray_ports() { + lsof -ti "TCP:${GRAY_WEB_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true + lsof -ti "TCP:${GRAY_API_PORT}" -sTCP:LISTEN 2>/dev/null | xargs kill 2>/dev/null || true +} + +rollback_gray() { + stop_gray_ports + echo "Gray startup failed; live memind_adm was not changed." >&2 +} +trap rollback_gray ERR + +live_api_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)" +live_web_before="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)" +[[ "${live_api_before}" == "200" && "${live_web_before}" == "200" ]] || { + echo "Live memind_adm is not healthy before gray deployment" >&2 + exit 1 +} + +for port in "${GRAY_WEB_PORT}" "${GRAY_API_PORT}"; do + if lsof -nP -iTCP:"${port}" -sTCP:LISTEN -t | grep -q .; then + echo "Gray port is already in use: ${port}" >&2 + exit 1 + fi +done + +cd "${INCOMING_DIR}" +shasum -a 256 -c "$(basename "${SHA_FILE}")" + +rm -rf "${RELEASE_DIR}" +mkdir -p "${RELEASE_DIR}" +tar -xzf "${BUNDLE}" -C "${RELEASE_DIR}" +cp "${MANIFEST}" "${RELEASE_DIR}/.release-manifest.txt" + +[[ -f "${LIVE_DIR}/.env" ]] || { echo "Live .env is missing" >&2; exit 1; } +cp "${LIVE_DIR}/.env" "${RELEASE_DIR}/.env" +chmod 600 "${RELEASE_DIR}/.env" + +[[ -d "${RELEASE_DIR}/dist" ]] || { echo "Gray release missing dist/" >&2; exit 1; } +[[ ! -d "${RELEASE_DIR}/src" ]] || { echo "Gray release contains src/" >&2; exit 1; } +[[ -f "${RELEASE_DIR}/server/index.mjs" ]] || { echo "Gray release missing Admin API" >&2; exit 1; } + +export PATH="/opt/homebrew/bin:/opt/homebrew/opt/node@24/bin:/opt/homebrew/opt/node@22/bin:/usr/local/bin:/usr/bin:/bin:${PATH}" +cd "${RELEASE_DIR}" +if [[ -f package-lock.json ]]; then + npm ci --no-audit --no-fund +else + npm install --no-audit --no-fund +fi + +set -a +# shellcheck disable=SC1091 +source "${RELEASE_DIR}/.env" +set +a +export ADM_API_PORT="${GRAY_API_PORT}" +export ADM_PORT="${GRAY_WEB_PORT}" +export ADM_API_HOST=127.0.0.1 +export ADM_WEB_HOST=127.0.0.1 +export ADM_DEV_BACKEND="http://127.0.0.1:${GRAY_API_PORT}" +export VITE_BASE_PATH=/ops +export ADM_READ_ONLY_MODE=1 + +nohup node server/index.mjs >>"${RELEASE_DIR}/adm-gray-api.log" 2>&1 & +echo $! >"${RELEASE_DIR}/.adm-gray-api.pid" + +api_ready=0 +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${GRAY_API_PORT}/health" >/dev/null; then + api_ready=1 + break + fi + sleep 1 +done +[[ "${api_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-api.log" >&2 || true; exit 1; } + +nohup ./node_modules/.bin/vite preview --config scripts/vite-preview.config.mjs \ + >>"${RELEASE_DIR}/adm-gray-web.log" 2>&1 & +echo $! >"${RELEASE_DIR}/.adm-gray-web.pid" + +web_ready=0 +for _ in $(seq 1 30); do + if curl -fsS "http://127.0.0.1:${GRAY_WEB_PORT}/ops/" >/dev/null; then + web_ready=1 + break + fi + sleep 1 +done +[[ "${web_ready}" == "1" ]] || { tail -n 80 "${RELEASE_DIR}/adm-gray-web.log" >&2 || true; exit 1; } + +live_api_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:8085/health || true)" +live_web_after="$(curl -sS -o /dev/null -w '%{http_code}' http://127.0.0.1:5174/ops/ || true)" +[[ "${live_api_after}" == "200" && "${live_web_after}" == "200" ]] || { + echo "Live memind_adm health changed during gray startup" >&2 + exit 1 +} + +cat >"${RELEASE_DIR}/GRAY_DEPLOYMENT.txt" < { + let updateCalled = false; + const service = { + getAdminConfig: async () => ({ config: { defaultProvider: 'mock', providers: {} } }), + updateAdminConfig: async () => { + updateCalled = true; + return { ok: true }; + }, + }; + const services = { + ...createServices({ imageMakeAdminConfigService: service }), + readOnlyMode: true, + }; + + await withServer(services, async (baseUrl) => { + const readResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { headers: adminHeaders }); + assert.equal(readResponse.status, 200); + + const writeResponse = await fetch(`${baseUrl}/admin-api/image-make/config`, { + method: 'PATCH', + headers: adminHeaders, + body: JSON.stringify({ defaultProvider: 'mock' }), + }); + assert.equal(writeResponse.status, 423); + assert.match((await writeResponse.json()).message, /灰度只读模式/); + assert.equal(updateCalled, false); + }); +}); diff --git a/server/app.mjs b/server/app.mjs index 615c25b..7e069f4 100644 --- a/server/app.mjs +++ b/server/app.mjs @@ -113,6 +113,8 @@ export function createAdminApp(services) { } = services; const app = express(); app.set('trust proxy', 1); + const readOnlyMode = services.readOnlyMode + ?? /^(1|true|yes|on)$/i.test(String(process.env.ADM_READ_ONLY_MODE ?? '')); const isSecureRequest = (req) => req.secure || req.get('x-forwarded-proto')?.split(',')[0]?.trim() === 'https'; @@ -150,6 +152,13 @@ export function createAdminApp(services) { ); } + const requireWritableMode = (req, res, next) => { + if (readOnlyMode && !['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { + return res.status(423).json({ message: '当前为灰度只读模式,禁止修改生产数据' }); + } + next(); + }; + app.get('/health', (_req, res) => { res.json({ ok: true, service: 'memind_adm' }); }); @@ -206,6 +215,8 @@ export function createAdminApp(services) { next(); })); + adminApi.use(requireWritableMode); + const requireAdmin = (req, res, next) => { if (!req.currentUser || req.currentUser.role !== 'admin') { res.status(403).json({ message: '需要管理员权限' }); @@ -1123,6 +1134,7 @@ export function createAdminApp(services) { req.currentUser = me; next(); })); + opsApi.use(requireWritableMode); opsApi.use(resolveSessionUser); opsApi.use('/ops/v1', services.createOpsApi({ jsonBody, plazaOps: services.plazaOps })); app.use('/api', opsApi);