feat: add isolated read-only gray deployment
This commit is contained in:
@@ -91,6 +91,11 @@ bash scripts/release-prod.sh --package-only
|
||||
# 候选包生成后,只读检查 103 灰度前置条件
|
||||
bash scripts/check-gray-ready.sh --candidate-dir release-candidates/<release-id>
|
||||
|
||||
# 明确批准后,部署 103 loopback-only 灰度实例;不切换 live
|
||||
bash scripts/deploy-gray-103.sh \
|
||||
--candidate-dir release-candidates/<release-id> \
|
||||
--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 原子切换,必须等灰度验收通过后再执行。
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+210
@@ -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" <<EOF
|
||||
release_id=${RELEASE_ID}
|
||||
deployed_at=$(date '+%Y-%m-%d %H:%M:%S %z')
|
||||
web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/
|
||||
api=http://127.0.0.1:${GRAY_API_PORT}
|
||||
read_only=true
|
||||
live_switched=false
|
||||
EOF
|
||||
|
||||
trap - ERR
|
||||
echo "gray_release_dir=${RELEASE_DIR}"
|
||||
echo "gray_web=http://127.0.0.1:${GRAY_WEB_PORT}/ops/"
|
||||
echo "gray_api=http://127.0.0.1:${GRAY_API_PORT}"
|
||||
echo "gray_read_only=true"
|
||||
echo "live_memind_adm_unchanged=true"
|
||||
REMOTE
|
||||
|
||||
echo
|
||||
echo "Gray instance deployed without switching live."
|
||||
echo "Open through SSH forwarding:"
|
||||
echo " ssh -L ${GRAY_WEB_PORT}:127.0.0.1:${GRAY_WEB_PORT} -L ${GRAY_API_PORT}:127.0.0.1:${GRAY_API_PORT} john@${HOST}"
|
||||
@@ -118,3 +118,32 @@ test('image_make admin routes require an admin user and an enabled service', asy
|
||||
assert.equal(unavailable.status, 503);
|
||||
});
|
||||
});
|
||||
|
||||
test('gray read-only mode blocks admin writes while keeping reads available', async () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user