加固既有生产部署流程(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -1,7 +1,5 @@
|
||||
require("dotenv/config");
|
||||
|
||||
const crypto = require("node:crypto");
|
||||
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { hashPassword } = require("../utils/crypto");
|
||||
const { validatePassword } = require("../utils/passwordPolicy");
|
||||
@@ -94,12 +92,6 @@ async function ensureDepartment(prisma, payload) {
|
||||
});
|
||||
}
|
||||
|
||||
function generateOneTimePassword() {
|
||||
// 确保满足 passwordPolicy:>=12 + 小写 + 大写 + 数字
|
||||
const suffix = crypto.randomBytes(6).toString("hex");
|
||||
return `TrialrunA1${suffix}`;
|
||||
}
|
||||
|
||||
async function ensureFeatureFlag(prisma, payload) {
|
||||
const now = new Date();
|
||||
const existing = await prisma.featureFlag.findUnique({
|
||||
@@ -248,7 +240,10 @@ async function ensureAdminEmployee(prisma, params) {
|
||||
return { employeeId: existing.id, created: false, password: null };
|
||||
}
|
||||
|
||||
const password = params.password || generateOneTimePassword();
|
||||
if (!params.password) {
|
||||
throw new Error('TRIALRUN_ADMIN_PASSWORD is required when creating the trialrun admin; credentials are never generated into deployment logs');
|
||||
}
|
||||
const password = params.password;
|
||||
const errors = validatePassword(password);
|
||||
if (errors.length) {
|
||||
throw new Error(`Invalid TRIALRUN_ADMIN_PASSWORD: ${errors.join("; ")}`);
|
||||
@@ -348,9 +343,7 @@ async function main() {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("Seeded trialrun admin:");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- email: ${adminEmail}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- phone: ${adminPhone}`);
|
||||
console.log("- login identifiers: configured");
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- created: ${result.created}`);
|
||||
// eslint-disable-next-line no-console
|
||||
@@ -361,9 +354,7 @@ async function main() {
|
||||
}
|
||||
if (result.password) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`- one-time password: ${result.password}`);
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(" (请立刻保存,并在首次登录后由管理员重置/更换密码)");
|
||||
console.log("- initial password: supplied by approved configuration; not logged");
|
||||
} else {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log("- password: unchanged");
|
||||
|
||||
@@ -31,7 +31,7 @@ function evaluateWecom(config) {
|
||||
return {
|
||||
state: 'degraded',
|
||||
detail: `企业微信基础配置已启用,但入口=${config.wecomEntryEnabled ? 'on' : 'off'} oauth=${config.wecomOauthEnabled ? 'on' : 'off'}`,
|
||||
checks: [`corpId=${config.wecomCorpId}`, `agentId=${config.wecomAgentId}`],
|
||||
checks: ['corpId=present', 'agentId=present'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -39,9 +39,9 @@ function evaluateWecom(config) {
|
||||
state: 'ready',
|
||||
detail: '企业微信入口与免登配置齐全',
|
||||
checks: [
|
||||
`corpId=${config.wecomCorpId}`,
|
||||
`agentId=${config.wecomAgentId}`,
|
||||
`redirectUri=${config.wecomRedirectUri}`,
|
||||
'corpId=present',
|
||||
'agentId=present',
|
||||
'redirectUri=present',
|
||||
`pcWebLogin=${config.wecomPcWebLoginEnabled ? 'on' : 'off'}`,
|
||||
],
|
||||
};
|
||||
@@ -66,7 +66,7 @@ function evaluateEmail(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: 'SMTP 基础配置齐全(仅做配置契约校验,不发真实邮件)',
|
||||
checks: [`from=${config.emailFrom}`, `host=${config.smtpHost}`, `port=${config.smtpPort}`, `secure=${config.smtpSecure}`],
|
||||
checks: ['from=present', 'host=present', 'port=valid', `secure=${config.smtpSecure}`],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -88,7 +88,7 @@ function evaluateOcr(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: '腾讯 OCR 配置齐全',
|
||||
checks: [`provider=${provider}`, `region=${config.tencentOcrRegion}`],
|
||||
checks: [`provider=${provider}`, 'credentials=present', 'region=present'],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -112,7 +112,7 @@ function evaluateBridge(config) {
|
||||
return {
|
||||
state: 'ready',
|
||||
detail: '消息回流桥接配置齐全',
|
||||
checks: [`appOrigin=${config.appOrigin}`],
|
||||
checks: ['appOrigin=present'],
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -203,9 +203,9 @@ async function main() {
|
||||
});
|
||||
}
|
||||
|
||||
assertCondition(Boolean(adminEmployee), `未找到 active admin 员工:${adminEmail}`);
|
||||
assertCondition(Boolean(adminEmployee), '未找到 active admin 员工');
|
||||
assertCondition(adminEmployee.status === 'active' && !adminEmployee.archived_at, `active admin 状态异常:${adminEmployee.status}`);
|
||||
console.log(`[PASS] 已找到 active admin 员工:${adminEmployee.employee_no}`);
|
||||
console.log('[PASS] 已找到 active admin 员工');
|
||||
|
||||
const workspace = await getWorkspaceSetting(prisma);
|
||||
assertWorkspaceSetting(workspace);
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
const { execFile } = require("node:child_process");
|
||||
const { mkdtemp, mkdir, rm, symlink, writeFile } = require("node:fs/promises");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { promisify } = require("node:util");
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const ROOT_DIR = path.resolve(__dirname, "../../../..");
|
||||
const PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/preflight-remote.sh");
|
||||
|
||||
describe("deployment safety contract", () => {
|
||||
let fixtureRoot;
|
||||
let remoteDir;
|
||||
let envFile;
|
||||
let postgresDir;
|
||||
let storageDir;
|
||||
|
||||
beforeEach(async () => {
|
||||
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), "hyb-oa-deploy-safety-"));
|
||||
remoteDir = path.join(fixtureRoot, "app");
|
||||
envFile = path.join(remoteDir, "deploy/trialrun/.env");
|
||||
postgresDir = path.join(fixtureRoot, "postgres");
|
||||
storageDir = path.join(fixtureRoot, "storage");
|
||||
await mkdir(path.join(remoteDir, "server"), { recursive: true });
|
||||
await mkdir(path.join(remoteDir, "deploy/trialrun"), { recursive: true });
|
||||
await mkdir(postgresDir, { recursive: true });
|
||||
await mkdir(storageDir, { recursive: true });
|
||||
await writeFile(path.join(remoteDir, "server/package.json"), "{}\n");
|
||||
await writeFile(path.join(remoteDir, "deploy/trialrun/docker-compose.yml"), "services: {}\n");
|
||||
await writeFile(envFile, "APP_ORIGIN=https://example.invalid\n", { mode: 0o600 });
|
||||
await writeFile(path.join(postgresDir, "PG_VERSION"), "16\n");
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(fixtureRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function preflight(overrides = {}) {
|
||||
return execFileAsync("bash", [PREFLIGHT], {
|
||||
env: {
|
||||
...process.env,
|
||||
REMOTE_DIR: remoteDir,
|
||||
REMOTE_ENV_FILE: envFile,
|
||||
COMPOSE_FILE: "deploy/trialrun/docker-compose.yml",
|
||||
POSTGRES_DATA_DIR: postgresDir,
|
||||
STORAGE_HOST_DIR: storageDir,
|
||||
DEPLOY_DATA_MODE: "existing",
|
||||
SYNC_ENV: "0",
|
||||
...overrides,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts a complete existing deployment contract", async () => {
|
||||
await expect(preflight()).resolves.toMatchObject({ stdout: expect.stringContaining("[remote-preflight][PASS]") });
|
||||
});
|
||||
|
||||
it("rejects a config directory masquerading as the repository root", async () => {
|
||||
await expect(preflight({ REMOTE_DIR: path.join(remoteDir, "deploy/trialrun") })).rejects.toMatchObject({ code: 1 });
|
||||
});
|
||||
|
||||
it("rejects a missing or symbolic remote env before synchronization", async () => {
|
||||
await rm(envFile);
|
||||
await expect(preflight()).rejects.toMatchObject({ code: 1 });
|
||||
await symlink(path.join(remoteDir, "missing.env"), envFile);
|
||||
await expect(preflight()).rejects.toMatchObject({ code: 1 });
|
||||
});
|
||||
|
||||
it("rejects invalid existing data directories", async () => {
|
||||
await rm(path.join(postgresDir, "PG_VERSION"));
|
||||
await expect(preflight()).rejects.toMatchObject({ code: 1 });
|
||||
});
|
||||
|
||||
it("runs remote preflight before rsync and reads existing contracts from systemd", async () => {
|
||||
const deploy = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy.sh"), "utf8");
|
||||
const wrapper = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8");
|
||||
expect(deploy.indexOf("REMOTE_PREFLIGHT_SCRIPT")).toBeGreaterThanOrEqual(0);
|
||||
expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("rsync -az --delete"));
|
||||
expect(deploy.indexOf('>>> 3. 同步前只读校验远端部署合同')).toBeLessThan(deploy.indexOf("mkdir -p '${REMOTE_DIR}'"));
|
||||
expect(wrapper).toContain("systemctl show '${DEPLOY_CONTRACT_UNIT}' -p Environment --value");
|
||||
expect(wrapper).not.toContain("docker inspect");
|
||||
expect(wrapper).not.toContain("working_dir");
|
||||
expect(wrapper).toContain("DEPLOY_DATA_MODE=existing");
|
||||
expect(wrapper).toContain("SYNC_ENV=0");
|
||||
});
|
||||
|
||||
it("keeps immutable build args after stable dependency layers", async () => {
|
||||
const dockerfile = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "deploy/trialrun/Dockerfile"), "utf8");
|
||||
expect(dockerfile.indexOf("RUN apk add --no-cache bash curl")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA"));
|
||||
expect(dockerfile.indexOf("RUN cd client && npm ci")).toBeLessThan(dockerfile.indexOf("ARG VITE_BUILD_SHA"));
|
||||
expect(dockerfile.indexOf("RUN cd server && npm ci")).toBeLessThan(dockerfile.indexOf("ARG BUILD_SHA"));
|
||||
});
|
||||
|
||||
it("keeps deployment smoke output free of raw account and external identifiers", async () => {
|
||||
const seed = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/seed-trialrun.js"), "utf8");
|
||||
const external = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-external-deps.js"), "utf8");
|
||||
const runtime = await require("node:fs/promises").readFile(path.join(ROOT_DIR, "server/src/scripts/smoke-trialrun-runtime.js"), "utf8");
|
||||
expect(seed).not.toContain("${adminEmail}");
|
||||
expect(seed).not.toContain("${adminPhone}");
|
||||
expect(seed).not.toContain("${result.password}");
|
||||
expect(seed).not.toContain("generateOneTimePassword");
|
||||
expect(external).not.toContain("corpId=${config.wecomCorpId}");
|
||||
expect(external).not.toContain("redirectUri=${config.wecomRedirectUri}");
|
||||
expect(external).not.toContain("from=${config.emailFrom}");
|
||||
expect(runtime).not.toContain("${adminEmployee.employee_no}");
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户