加固既有生产部署流程(07-27-yang-jun-reimbursement-readiness)

这个提交包含在:
2026-08-01 18:25:20 +08:00
父节点 5dac336fbf
当前提交 07de198b51
共修改 15 个文件,包含 375 行新增和 34 行删除
+4 -5
查看文件
@@ -1,10 +1,5 @@
FROM node:22-alpine AS base
ARG BUILD_SHA=unknown
ARG VITE_BUILD_SHA=unknown
ENV BUILD_SHA=$BUILD_SHA
ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
WORKDIR /app
RUN apk add --no-cache bash curl
@@ -14,6 +9,8 @@ COPY client/package.json client/package-lock.json ./client/
RUN cd client && npm ci
FROM client_deps AS client_build
ARG VITE_BUILD_SHA=unknown
ENV VITE_BUILD_SHA=$VITE_BUILD_SHA
COPY client ./client
RUN cd client && npm run build
@@ -23,6 +20,8 @@ COPY server/prisma ./server/prisma
RUN cd server && npm ci
FROM server_deps AS server_build
ARG BUILD_SHA=unknown
ENV BUILD_SHA=$BUILD_SHA
COPY server ./server
COPY scripts ./scripts
COPY --from=client_build /app/client/dist ./client/dist
+4 -1
查看文件
@@ -71,7 +71,10 @@
- [ ] 本地工作树 clean,目标 commit 已进入 upstream;部署日志中的 commit 与 `/ready.data.buildSha` 一致
- [ ] **EXTERNAL_GATED**:操作人已在边缘暂停 `/api/auth/forgot-password`、`/api/auth/reset-password`、`/api/auth/email-login/code`、`/api/auth/email-login/verify`;部署脚本不会自动修改边缘配置,未人工确认四端点关闭不得发布,配置预检、迁移、新应用和 smoke 全部通过后才人工恢复
- [ ] 标准路径已成功刷新 upstream;若因受控离线恢复使用 unpublished override,日志包含批准原因码和 `UNPUBLISHED-OVERRIDE` 标识
- [ ] 标准发布通过 `bash deploy/trialrun/deploy.sh` 执行;脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 已有部署通过 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh` 执行;首次初始化才使用完整显式参数的 `deploy.sh`
- [ ] 未从 Compose `working_dir` 猜仓库根目录;同步前 preflight 已确认仓库根标识、普通远端 `.env`、PG_VERSION 与附件目录,且该检查发生在 rsync 之前
- [ ] 脚本自动注入完整后端 SHA 与 12 位前端 SHA,并完成构建、init 与 smoke
- [ ] 构建日志显示 lockfile 未变化时 client/server 依赖层使用缓存;build SHA 变化没有触发 `apk add` 或两次 `npm ci`
- [ ] 发布命令显式携带批准的新 project、两个绝对数据路径和 `DEPLOY_DATA_MODE=existing`;未依赖 `.env.example` 相对默认值
- [ ] 若为首次初始化或受控应急而直接使用 Compose,已显式设置 `BUILD_SHA=<approved-full-commit>` 与 `VITE_BUILD_SHA=<matching-12-char-commit>`;未使用 `unknown`
- [ ] 探活:
+48
查看文件
@@ -0,0 +1,48 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
DEPLOY_SERVER="${DEPLOY_SERVER:-}"
DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}"
fail() {
echo "[deploy-existing][ERROR] $1" >&2
exit 1
}
[[ "$DEPLOY_SERVER" =~ ^([A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$ ]] \
|| fail "必须显式提供安全的 DEPLOY_SERVER"
[[ "$DEPLOY_CONTRACT_UNIT" =~ ^[A-Za-z0-9_.@-]+\.service$ ]] \
|| fail "DEPLOY_CONTRACT_UNIT 格式无效"
command -v ssh >/dev/null 2>&1 || fail "缺少 ssh"
unit_environment="$(ssh "$DEPLOY_SERVER" \
"systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value")"
[[ -n "$unit_environment" ]] || fail "无法读取既有部署合同 unit"
declare -A contract=()
for entry in $unit_environment; do
key="${entry%%=*}"
value="${entry#*=}"
case "$key" in
REMOTE_DIR|REMOTE_ENV_FILE|COMPOSE_FILE|COMPOSE_PROJECT_NAME|POSTGRES_DATA_DIR|STORAGE_HOST_DIR)
contract["$key"]="$value"
;;
esac
done
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME POSTGRES_DATA_DIR STORAGE_HOST_DIR; do
[[ -n "${contract[$key]:-}" ]] || fail "既有部署合同缺少 $key"
done
echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显"
DEPLOY_SERVER="$DEPLOY_SERVER" \
REMOTE_DIR="${contract[REMOTE_DIR]}" \
REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \
COMPOSE_FILE="${contract[COMPOSE_FILE]}" \
COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \
POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \
STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \
DEPLOY_DATA_MODE=existing \
SYNC_ENV=0 \
bash "$ROOT_DIR/deploy/trialrun/deploy.sh"
+12 -1
查看文件
@@ -21,6 +21,7 @@ ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js"
RUNTIME_SMOKE_SCRIPT="server/src/scripts/smoke-trialrun-runtime.js"
EXTERNAL_DEPS_SMOKE_SCRIPT="server/src/scripts/smoke-external-deps.js"
PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs"
REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"
STAGING_DIR=""
RSYNC_PROTECT_ARGS=()
@@ -148,6 +149,11 @@ if [[ ! -f "${ROOT_DIR}/${EXTERNAL_DEPS_SMOKE_SCRIPT}" ]]; then
exit 1
fi
if [[ ! -f "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}" ]]; then
echo "[ERROR] 缺少 ${REMOTE_PREFLIGHT_SCRIPT}" >&2
exit 1
fi
if [[ "${SYNC_ENV}" == "1" && ! -f "${ROOT_DIR}/${LOCAL_ENV_FILE}" ]]; then
echo "[ERROR] SYNC_ENV=1 但本地不存在 ${LOCAL_ENV_FILE}" >&2
exit 1
@@ -160,7 +166,12 @@ echo ">>> 2. 从已验证提交生成不可变同步目录"
STAGING_DIR="$(mktemp -d "${TMPDIR:-/tmp}/hyb-oa-deploy.XXXXXX")"
git -C "$ROOT_DIR" archive --format=tar "$SOURCE_COMMIT" | tar -xf - -C "$STAGING_DIR"
echo ">>> 3. 创建远端目录"
echo ">>> 3. 同步前只读校验远端部署合同"
ssh "${DEPLOY_SERVER}" \
"REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' SYNC_ENV='${SYNC_ENV}' bash -s" \
< "${ROOT_DIR}/${REMOTE_PREFLIGHT_SCRIPT}"
echo ">>> 3.1 创建远端目录"
ssh "${DEPLOY_SERVER}" "mkdir -p '${REMOTE_DIR}'"
echo ">>> 4. 同步已验证提交到服务器"
+27
查看文件
@@ -0,0 +1,27 @@
#!/usr/bin/env bash
set -euo pipefail
fail() {
echo "[remote-preflight][ERROR] $1" >&2
exit 1
}
for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE POSTGRES_DATA_DIR STORAGE_HOST_DIR DEPLOY_DATA_MODE SYNC_ENV; do
[[ -n "${!key:-}" ]] || fail "缺少 $key"
done
if [[ "$DEPLOY_DATA_MODE" == "existing" ]]; then
[[ -d "$REMOTE_DIR" \
&& -f "$REMOTE_DIR/server/package.json" \
&& -f "$REMOTE_DIR/$COMPOSE_FILE" ]] \
|| fail "existing 模式要求 REMOTE_DIR 是现存仓库根目录"
[[ -f "$POSTGRES_DATA_DIR/PG_VERSION" && -d "$STORAGE_HOST_DIR" ]] \
|| fail "existing 模式的数据目录合同未满足"
fi
if [[ "$SYNC_ENV" == "0" ]]; then
[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" ]] \
|| fail "SYNC_ENV=0 要求同步前已有普通远端 .env 文件"
fi
echo "[remote-preflight][PASS] 远端仓库、环境与数据合同通过"
+6 -2
查看文件
@@ -40,6 +40,7 @@ curl -fsS <shadow-ready-url>
- 反向代理:`caddy` 正在运行,`oa.hyinda.com` 应反代到 `.env` 中 `APP_HOST_PORT` 对应的宿主回环端口。
- 部署目录通过 `REMOTE_DIR` 显式传入。
- 远端目录当前不是 git 工作树;发布应优先从本地执行 `bash deploy/trialrun/deploy.sh`,由脚本 `rsync` 到服务器
- 已有部署优先执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`;包装脚本只从受控 automatic-backup systemd unit 读取 allowlist 参数且不回显路径。不得把 Compose `com.docker.compose.project.working_dir` 当仓库根目录,该标签可能指向 `deploy/trialrun` 配置目录。
- 远端容器编排当前实际为 `docker-compose 1.29.2`;本 runbook 命令以 `docker-compose` 为准
- 所有 Compose 命令必须显式使用同一个 `COMPOSE_PROJECT_NAME` 和 `-p`;不得依赖目录名推导项目身份,也不得写死容器名。
- 当前公网 `.env` 已确认:`APP_ORIGIN=https://oa.hyinda.com`、`COOKIE_SECURE=true`、`PUBLIC_SECURITY_HARDENING_ENABLED=true`
@@ -121,7 +122,7 @@ docker-compose -p "$COMPOSE_PROJECT_NAME" -f deploy/trialrun/docker-compose.yml
说明:
- `init` 是一次性任务:`migrate:deploy + seed:config + seed:trialrun`。`seed:config` 仅在审批规则表为空时创建默认三档;已有任意审批规则时必须全部保留,生产规则变更另走获批的原子事务。
- 初始化必须发生在依赖新增列的新 server 切换之前;正式部署脚本会先用新 init 镜像校验生产配置并执行迁移,再替换 server。
- 若 `TRIALRUN_ADMIN_PASSWORD` 为空,seed 会生成一次性口令并输出到控制台(请立刻保存)。
- 首次创建试运行管理员前必须通过受控 `.env` 提供 `TRIALRUN_ADMIN_PASSWORD`;seed 不生成或输出口令,登录标识也不进入部署日志。
- 正式启用前,推荐先保持“空基线 + 默认种子”,不要直接导入开发库、测试库或旧 OA 整库备份。
- 如确需导入,仅导入最小必要主数据:部门、员工、角色、在用资产、活跃项目、合同元数据。
- 历史审批单、通知历史、审计日志、聊天记录以及涉密正文/附件继续保留在原系统、文件盘或离线档案中。
@@ -392,7 +393,10 @@ systemctl status hyb-oa-public-app-health.service --no-pager
步骤:
1) 先做备份(见 E14 脚本)
2) 从本地仓库执行 `DEPLOY_SERVER=<ssh-target> REMOTE_DIR=<remote-dir> COMPOSE_PROJECT_NAME=<approved-compose-project> POSTGRES_DATA_DIR=<absolute-pgdata> STORAGE_HOST_DIR=<absolute-storage> DEPLOY_DATA_MODE=existing bash deploy/trialrun/deploy.sh`(推荐);脚本只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。`REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;任一方向嵌套都会在 SSH 和 `rsync --delete` 前阻断。位于同步树内的 `REMOTE_ENV_FILE` 按实际相对路径动态保护。
2) 已有部署从本地仓库执行 `DEPLOY_SERVER=<ssh-target> bash deploy/trialrun/deploy-existing.sh`(推荐);它从受控 systemd unit 读取既有 `REMOTE_DIR`、env、Compose project 与数据目录合同,不从 Docker 标签猜路径,也不回显参数值。首次初始化或 unit 不存在时,才使用完整显式参数执行 `deploy.sh`。
- `deploy.sh` 只同步已验证提交的 Git archive,不依赖远端 `git pull`,也不会把本地 ignored 文件带入发布。
- 在任何 rsync 前,远端 preflight 必须确认 existing 目标同时含 `server/package.json` 与 Compose 文件、PGDATA 含 `PG_VERSION`、storage 存在,且 `SYNC_ENV=0` 的 `.env` 是普通文件。失败即停止,不能先同步再验证。
- `REMOTE_DIR` 必须与 PostgreSQL、附件宿主目录完全分离;位于同步树内的 `REMOTE_ENV_FILE` 按精确相对路径动态保护。
- 在现网目录统一任务中,新的 `COMPOSE_PROJECT_NAME`、`POSTGRES_DATA_DIR`、`STORAGE_HOST_DIR` 必须与活动数据目录复制在同一停机窗口启用;本地提交本身不授权切换。
- 旧栈停止命令必须使用冻结阶段由 `docker compose ls` 实测的旧 project;不得拿新 project 名去停止旧容器。
- `artifacts/` 和 `output/` 是运行期证据/截图目录,不作为源码发布输入,也不由 `rsync --delete` 清理远端历史证据。
+46
查看文件
@@ -16,6 +16,9 @@ const checklistPath = path.join(rootDir, 'deploy/trialrun/checklist.md');
const deployScriptPath = path.resolve(
process.env.ENV_CONTRACT_DEPLOY_SCRIPT_PATH || path.join(rootDir, 'deploy/trialrun/deploy.sh'),
);
const deployExistingScriptPath = path.join(rootDir, 'deploy/trialrun/deploy-existing.sh');
const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh');
const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile');
const timerScriptPath = path.resolve(
process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH
|| path.join(rootDir, 'deploy/trialrun/install-warch3-ops-timer.sh'),
@@ -155,6 +158,9 @@ const composeContent = readFile(composePath);
const runbookContent = readFile(runbookPath);
const checklistContent = readFile(checklistPath);
const deployScriptContent = readFile(deployScriptPath);
const deployExistingScriptContent = readFile(deployExistingScriptPath);
const remotePreflightScriptContent = readFile(remotePreflightScriptPath);
const dockerfileContent = readFile(dockerfilePath);
const timerScriptContent = readFile(timerScriptPath);
const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath);
const offhostReceiverInstallerContent = readFile(offhostReceiverInstallerPath);
@@ -439,12 +445,52 @@ for (const requiredFragment of [
'DEPLOY_DATA_MODE" != "existing"',
'DEPLOY_DATA_MODE" != "initialize"',
'"$POSTGRES_DATA_DIR/PG_VERSION"',
'>>> 3. 同步前只读校验远端部署合同',
'REMOTE_PREFLIGHT_SCRIPT="deploy/trialrun/preflight-remote.sh"',
]) {
if (!deployScriptContent.includes(requiredFragment)) {
errors.push(`deploy.sh 缺少正式部署数据目录护栏: ${requiredFragment}`);
}
}
if (deployScriptContent.indexOf('>>> 3. 同步前只读校验远端部署合同')
> deployScriptContent.indexOf('rsync -az --delete')) {
errors.push('deploy.sh 必须在 rsync 前执行远端只读 preflight');
}
for (const requiredFragment of [
'systemctl show',
'DEPLOY_DATA_MODE=existing',
'SYNC_ENV=0',
'路径与标识不回显',
]) {
if (!deployExistingScriptContent.includes(requiredFragment)) {
errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`);
}
}
for (const requiredFragment of [
'REMOTE_DIR 是现存仓库根目录',
'SYNC_ENV=0 要求同步前已有普通远端 .env 文件',
'"$POSTGRES_DATA_DIR/PG_VERSION"',
]) {
if (!remotePreflightScriptContent.includes(requiredFragment)) {
errors.push(`preflight-remote.sh 缺少同步前护栏: ${requiredFragment}`);
}
}
for (const [earlier, later] of [
['RUN apk add --no-cache bash curl', 'ARG VITE_BUILD_SHA'],
['RUN cd client && npm ci', 'ARG VITE_BUILD_SHA'],
['RUN cd server && npm ci', 'ARG BUILD_SHA'],
]) {
if (dockerfileContent.indexOf(earlier) === -1
|| dockerfileContent.indexOf(later) === -1
|| dockerfileContent.indexOf(earlier) > dockerfileContent.indexOf(later)) {
errors.push(`Dockerfile 缓存顺序无效: ${earlier} 必须早于 ${later}`);
}
}
for (const { filePath, content } of nginxContents) {
const relativePath = path.relative(rootDir, filePath);
if (content.includes('$proxy_add_x_forwarded_for')) {