fix: 分离日常发布与首次初始化(09-09-existing-deploy-seed-separation)
这个提交包含在:
@@ -15,6 +15,7 @@
|
||||
"prisma:check-generated": "node src/scripts/check-prisma-client-generated.js",
|
||||
"prisma:migrate:dev": "prisma migrate dev",
|
||||
"prisma:migrate:deploy": "prisma migrate deploy",
|
||||
"deploy:init": "node src/scripts/deploy-init.js",
|
||||
"seed:config": "node src/scripts/seed-config.js",
|
||||
"apply:ceo-finance-fallback": "node src/scripts/apply-ceo-finance-fallback.js",
|
||||
"seed:dev-users": "node src/scripts/seed-dev.js",
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
|
||||
function executeCommand(command, args) {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: path.resolve(__dirname, "../.."),
|
||||
env: process.env,
|
||||
stdio: "inherit",
|
||||
});
|
||||
if (result.error || result.status !== 0) {
|
||||
throw new Error("deployment.init_command_failed");
|
||||
}
|
||||
}
|
||||
|
||||
async function inspectRequiredConfiguration(prisma) {
|
||||
return prisma.$transaction(async (transaction) => {
|
||||
await transaction.$executeRaw`SET TRANSACTION READ ONLY`;
|
||||
const checks = [
|
||||
{
|
||||
code: "expense_types",
|
||||
count: await transaction.expenseType.count({ where: { enabled: true } }),
|
||||
maintenance: "管理员核对 /api/expense-types;缺失数据需另批修复",
|
||||
},
|
||||
{
|
||||
code: "approval_rules",
|
||||
count: await transaction.approvalRule.count({ where: { enabled: true } }),
|
||||
maintenance: "/system/approval-rules",
|
||||
},
|
||||
{
|
||||
code: "reimbursement_workflow",
|
||||
count: await transaction.workflowDefinition.count({
|
||||
where: { entity_type: "reimbursement", is_active: true },
|
||||
}),
|
||||
maintenance: "/system/workflow-definitions",
|
||||
},
|
||||
];
|
||||
return checks;
|
||||
}, { isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
|
||||
}
|
||||
|
||||
async function checkRequiredConfiguration() {
|
||||
const { createPrismaClient } = require("../prismaClient");
|
||||
const { prisma, pool } = createPrismaClient();
|
||||
try {
|
||||
return await inspectRequiredConfiguration(prisma);
|
||||
} finally {
|
||||
try {
|
||||
await prisma.$disconnect();
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function runInitialization({
|
||||
mode,
|
||||
runCommand = executeCommand,
|
||||
checkConfiguration = checkRequiredConfiguration,
|
||||
report = (result) => process.stdout.write(JSON.stringify(result) + "\n"),
|
||||
}) {
|
||||
if (mode !== "existing" && mode !== "initialize") {
|
||||
throw new Error("deployment.data_mode_required");
|
||||
}
|
||||
report({ stage: "validate_config", mode });
|
||||
await runCommand(process.execPath, ["-e", 'require("./src/config").getConfig()']);
|
||||
report({ stage: "migrate", mode });
|
||||
await runCommand("npm", ["run", "prisma:migrate:deploy"]);
|
||||
if (mode === "initialize") {
|
||||
for (const script of ["seed:config", "seed:trialrun"]) {
|
||||
report({ stage: script, mode });
|
||||
await runCommand("npm", ["run", script]);
|
||||
}
|
||||
}
|
||||
report({ stage: "check_required_configuration", mode });
|
||||
const checks = await checkConfiguration();
|
||||
const missing = checks.filter((check) => check.count === 0);
|
||||
if (missing.length) {
|
||||
report({ status: "REQUIRED_CONFIGURATION_MISSING", checks: missing });
|
||||
throw new Error("deployment.required_configuration_missing");
|
||||
}
|
||||
report({ status: "INIT_PASS", mode, checks });
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
runInitialization({ mode: process.env.DEPLOY_DATA_MODE }).catch((error) => {
|
||||
const allowed = new Set([
|
||||
"deployment.data_mode_required",
|
||||
"deployment.init_command_failed",
|
||||
"deployment.required_configuration_missing",
|
||||
]);
|
||||
process.stderr.write(JSON.stringify({
|
||||
status: "INIT_FAILED",
|
||||
code: allowed.has(error.message) ? error.message : "deployment.init_failed",
|
||||
}) + "\n");
|
||||
process.exitCode = 1;
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runInitialization, inspectRequiredConfiguration };
|
||||
@@ -109,7 +109,8 @@ function main() {
|
||||
if (LIFECYCLE.has(call.operation)) {
|
||||
if (call.operation === 'up' && call.targets.includes('db')) fs.writeFileSync(path.join(root, 'db-started'), '1');
|
||||
if (call.operation === 'run') {
|
||||
if (call.targets.join() !== 'init' || !call.containerArgs.join(' ').includes('npm run prisma:migrate:deploy && npm run seed:config && npm run seed:trialrun')) {
|
||||
if (call.targets.join() !== 'init' || call.containerArgs.join(' ') !== '-lc cd /app/server && npm run deploy:init' ||
|
||||
!args.includes('DEPLOY_DATA_MODE=' + process.env.DEPLOY_DATA_MODE)) {
|
||||
throw new Error('unexpected_init_command');
|
||||
}
|
||||
if (config.initFails) process.exit(23);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const { createHash, randomBytes } = require("node:crypto");
|
||||
const path = require("node:path");
|
||||
|
||||
const testUrl = process.env.DEPLOY_INIT_TEST_DATABASE_URL;
|
||||
const configurationModels = [
|
||||
"expenseType", "bankInstitution", "approvalRule", "documentSequence",
|
||||
"documentCategory", "workflowDefinition", "leaveType", "leavePolicy",
|
||||
"featureFlag", "holidayCalendar", "holidayCalendarDay", "attendancePolicy",
|
||||
"budgetControlRule", "department", "employee", "employeeRole", "workspaceSetting",
|
||||
];
|
||||
|
||||
describe.skipIf(!testUrl)("real init against an explicitly supplied empty synthetic database", () => {
|
||||
let prisma;
|
||||
let pool;
|
||||
let environment;
|
||||
const originalUrl = process.env.DATABASE_URL;
|
||||
|
||||
beforeAll(async () => {
|
||||
const parsed = new URL(testUrl);
|
||||
if (parsed.hostname !== "127.0.0.1" || !/^\/hyb_seed_test_[a-z0-9]+$/.test(parsed.pathname)) {
|
||||
throw new Error("synthetic_loopback_database_required");
|
||||
}
|
||||
process.env.DATABASE_URL = testUrl;
|
||||
({ prisma, pool } = require("../../../src/prismaClient").createPrismaClient());
|
||||
const tables = await prisma.$queryRaw`SELECT count(*)::int AS total FROM information_schema.tables WHERE table_schema = 'public'`;
|
||||
expect(tables[0].total).toBe(0);
|
||||
environment = {
|
||||
PATH: process.env.PATH, NODE_ENV: "test", DATABASE_URL: testUrl,
|
||||
JWT_SECRET: randomBytes(32).toString("hex"),
|
||||
TRIALRUN_ADMIN_EMAIL: "[email protected]",
|
||||
TRIALRUN_ADMIN_PHONE: "19900000000",
|
||||
TRIALRUN_ADMIN_PASSWORD: randomBytes(24).toString("hex") + "Aa1!",
|
||||
TRIALRUN_ADMIN_NAME: "Synthetic Admin",
|
||||
TRIALRUN_ADMIN_EMPLOYEE_NO: "SEEDTEST",
|
||||
TRIALRUN_EXTRA_FORCE_ON_FLAGS: "",
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
try {
|
||||
if (prisma) await prisma.$disconnect();
|
||||
} finally {
|
||||
if (pool) await pool.end();
|
||||
if (originalUrl === undefined) delete process.env.DATABASE_URL;
|
||||
else process.env.DATABASE_URL = originalUrl;
|
||||
}
|
||||
});
|
||||
|
||||
function run(mode) {
|
||||
return spawnSync(process.execPath, ["src/scripts/deploy-init.js"], {
|
||||
cwd: path.resolve(__dirname, "../../.."),
|
||||
env: { ...environment, DEPLOY_DATA_MODE: mode },
|
||||
encoding: "utf8", timeout: 120000, maxBuffer: 4 * 1024 * 1024,
|
||||
});
|
||||
}
|
||||
|
||||
async function snapshot() {
|
||||
const hashes = {};
|
||||
for (const model of configurationModels) {
|
||||
const rows = await prisma[model].findMany();
|
||||
const serialized = rows.map((row) => JSON.stringify(row)).sort();
|
||||
hashes[model] = createHash("sha256").update(JSON.stringify(serialized)).digest("hex");
|
||||
}
|
||||
return hashes;
|
||||
}
|
||||
|
||||
it("initializes an empty database and preserves customized configuration through repeated existing runs and missing-config failures", async () => {
|
||||
const initialized = run("initialize");
|
||||
expect(initialized.status).toBe(0);
|
||||
expect(initialized.stdout).toContain('"status":"INIT_PASS"');
|
||||
const flag = await prisma.featureFlag.findFirstOrThrow();
|
||||
await prisma.featureFlag.update({
|
||||
where: { id: flag.id },
|
||||
data: { override_mode: "force_off", rollout_type: "department_allowlist", department_ids: [], version: 77, note: "synthetic customization" },
|
||||
});
|
||||
const workflow = await prisma.workflowDefinition.findFirstOrThrow({ where: { entity_type: "reimbursement" } });
|
||||
await prisma.workflowDefinition.update({ where: { id: workflow.id }, data: { version: 42, name: "Synthetic custom workflow" } });
|
||||
const holiday = await prisma.holidayCalendarDay.findFirstOrThrow();
|
||||
await prisma.holidayCalendarDay.update({ where: { id: holiday.id }, data: { name: "Synthetic custom holiday" } });
|
||||
const expected = await snapshot();
|
||||
const migrations = await prisma.$queryRaw`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL ORDER BY migration_name`;
|
||||
expect(migrations.length).toBeGreaterThan(0);
|
||||
for (const attempt of [1, 2]) {
|
||||
const existing = run("existing");
|
||||
expect(existing.status, "existing attempt " + attempt).toBe(0);
|
||||
expect(existing.stdout).toContain('"status":"INIT_PASS"');
|
||||
expect(existing.stdout).not.toContain('"stage":"seed:');
|
||||
expect(await snapshot()).toEqual(expected);
|
||||
}
|
||||
for (const [model, field, where] of [
|
||||
["expenseType", "enabled", {}],
|
||||
["approvalRule", "enabled", {}],
|
||||
["workflowDefinition", "is_active", { entity_type: "reimbursement" }],
|
||||
]) {
|
||||
const enabled = await prisma[model].findMany({ where: { ...where, [field]: true }, select: { id: true } });
|
||||
await prisma[model].updateMany({ where, data: { [field]: false } });
|
||||
const beforeFailure = await snapshot();
|
||||
const failed = run("existing");
|
||||
expect(failed.status).toBe(1);
|
||||
expect(failed.stdout).toContain("REQUIRED_CONFIGURATION_MISSING");
|
||||
expect(failed.stdout).not.toContain('"status":"INIT_PASS"');
|
||||
expect(await snapshot()).toEqual(beforeFailure);
|
||||
await prisma[model].updateMany({ where: { id: { in: enabled.map((row) => row.id) } }, data: { [field]: true } });
|
||||
}
|
||||
expect(await prisma.$queryRaw`SELECT migration_name FROM "_prisma_migrations" WHERE finished_at IS NOT NULL ORDER BY migration_name`).toEqual(migrations);
|
||||
}, 240000);
|
||||
});
|
||||
@@ -0,0 +1,142 @@
|
||||
const { runInitialization, inspectRequiredConfiguration } = require("../../../src/scripts/deploy-init");
|
||||
const { spawnSync } = require("node:child_process");
|
||||
const path = require("node:path");
|
||||
const fs = require("node:fs");
|
||||
const YAML = require("yaml");
|
||||
const os = require("node:os");
|
||||
|
||||
const checks = [
|
||||
{ code: "expense_types", count: 2 },
|
||||
{ code: "approval_rules", count: 1 },
|
||||
{ code: "reimbursement_workflow", count: 1 },
|
||||
];
|
||||
|
||||
describe("deployment init mode separation", () => {
|
||||
it.each(["valid", "missing", "unsafe_default", "wrong_service"])("enforces the init-only environment contract: %s", (scenario) => {
|
||||
const root = path.resolve(__dirname, "../../../..");
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "hyb-seed-env-"));
|
||||
try {
|
||||
let content = fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8");
|
||||
if (scenario === "missing") content = content.replace(' DEPLOY_DATA_MODE: ${DEPLOY_DATA_MODE:-}\n', "");
|
||||
if (scenario === "unsafe_default") content = content.replace('${DEPLOY_DATA_MODE:-}', '${DEPLOY_DATA_MODE:-initialize}');
|
||||
if (scenario === "wrong_service") content = content.replace(" NODE_ENV: production", " NODE_ENV: production\n DEPLOY_DATA_MODE: existing");
|
||||
const file = path.join(directory, "compose.yml");
|
||||
fs.writeFileSync(file, content);
|
||||
const result = spawnSync(process.execPath, [path.join(root, "deploy/trialrun/validate-env-contract.js")], {
|
||||
env: { ...process.env, ENV_CONTRACT_COMPOSE_PATH: file }, encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(scenario === "valid" ? 0 : 1);
|
||||
if (scenario !== "valid") expect(result.stdout + result.stderr).toContain("DEPLOY_DATA_MODE");
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["existing", "initialize"])("executes the complete %s path in order", async (mode) => {
|
||||
const calls = [];
|
||||
const report = vi.fn();
|
||||
await runInitialization({
|
||||
mode, report,
|
||||
runCommand: async (command, args) => calls.push(args.join(" ")),
|
||||
checkConfiguration: async () => { calls.push("check"); return checks; },
|
||||
});
|
||||
expect(calls).toEqual([
|
||||
'-e require("./src/config").getConfig()',
|
||||
"run prisma:migrate:deploy",
|
||||
...(mode === "initialize" ? ["run seed:config", "run seed:trialrun"] : []),
|
||||
"check",
|
||||
]);
|
||||
expect(report).toHaveBeenLastCalledWith({ status: "INIT_PASS", mode, checks });
|
||||
});
|
||||
|
||||
it.each([undefined, "", "repair", " existing", "INITIALIZE"])("rejects invalid mode %s before any access", async (mode) => {
|
||||
const runCommand = vi.fn();
|
||||
const checkConfiguration = vi.fn();
|
||||
await expect(runInitialization({ mode, runCommand, checkConfiguration })).rejects.toThrow("deployment.data_mode_required");
|
||||
expect(runCommand).not.toHaveBeenCalled();
|
||||
expect(checkConfiguration).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([0, 1, 2, 3])("stops initialize when subcommand %s fails", async (failureIndex) => {
|
||||
const runCommand = vi.fn().mockImplementation(async () => {
|
||||
if (runCommand.mock.calls.length === failureIndex + 1) throw new Error("synthetic_failure");
|
||||
});
|
||||
const checkConfiguration = vi.fn();
|
||||
const report = vi.fn();
|
||||
await expect(runInitialization({ mode: "initialize", runCommand, checkConfiguration, report })).rejects.toThrow("synthetic_failure");
|
||||
expect(runCommand).toHaveBeenCalledTimes(failureIndex + 1);
|
||||
expect(checkConfiguration).not.toHaveBeenCalled();
|
||||
expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false);
|
||||
});
|
||||
|
||||
it.each(checks.map((check) => check.code))("stops for missing %s without repair", async (missingCode) => {
|
||||
const runCommand = vi.fn();
|
||||
const report = vi.fn();
|
||||
await expect(runInitialization({
|
||||
mode: "existing", runCommand, report,
|
||||
checkConfiguration: async () => checks.map((check) => ({ ...check, count: check.code === missingCode ? 0 : check.count })),
|
||||
})).rejects.toThrow("deployment.required_configuration_missing");
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false);
|
||||
});
|
||||
|
||||
it("does not succeed or seed on a read failure", async () => {
|
||||
const runCommand = vi.fn();
|
||||
const report = vi.fn();
|
||||
await expect(runInitialization({
|
||||
mode: "existing", runCommand, report,
|
||||
checkConfiguration: async () => { throw new Error("query_failure"); },
|
||||
})).rejects.toThrow("query_failure");
|
||||
expect(runCommand).toHaveBeenCalledTimes(2);
|
||||
expect(report.mock.calls.some(([value]) => value.status === "INIT_PASS")).toBe(false);
|
||||
});
|
||||
|
||||
it("uses a bounded read-only transaction and the existing route predicates", async () => {
|
||||
const transaction = {
|
||||
$executeRaw: vi.fn().mockResolvedValue(0),
|
||||
expenseType: { count: vi.fn().mockResolvedValue(4) },
|
||||
approvalRule: { count: vi.fn().mockResolvedValue(2) },
|
||||
workflowDefinition: { count: vi.fn().mockResolvedValue(1) },
|
||||
};
|
||||
const prisma = { $transaction: vi.fn(async (callback) => callback(transaction)) };
|
||||
const result = await inspectRequiredConfiguration(prisma);
|
||||
expect(transaction.$executeRaw.mock.calls[0][0].join("")).toBe("SET TRANSACTION READ ONLY");
|
||||
expect(transaction.expenseType.count).toHaveBeenCalledWith({ where: { enabled: true } });
|
||||
expect(transaction.approvalRule.count).toHaveBeenCalledWith({ where: { enabled: true } });
|
||||
expect(transaction.workflowDefinition.count).toHaveBeenCalledWith({ where: { entity_type: "reimbursement", is_active: true } });
|
||||
expect(prisma.$transaction.mock.calls[0][1]).toEqual({ isolationLevel: "RepeatableRead", maxWait: 5000, timeout: 10000 });
|
||||
expect(result.map((check) => check.count)).toEqual([4, 2, 1]);
|
||||
});
|
||||
|
||||
it("shares one entry with Compose and has no implicit initialize default", () => {
|
||||
const root = path.resolve(__dirname, "../../../..");
|
||||
const compose = YAML.parse(fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8"));
|
||||
expect(compose.services.init.command).toEqual(["cd /app/server && npm run deploy:init"]);
|
||||
expect(compose.services.init.environment.DEPLOY_DATA_MODE).toBe("${DEPLOY_DATA_MODE:-}");
|
||||
const result = spawnSync(process.execPath, ["src/scripts/deploy-init.js"], {
|
||||
cwd: path.join(root, "server"),
|
||||
env: { PATH: process.env.PATH, DEPLOY_DATA_MODE: "" }, encoding: "utf8",
|
||||
});
|
||||
expect(result.status).toBe(1);
|
||||
expect(JSON.parse(result.stderr).code).toBe("deployment.data_mode_required");
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
|
||||
it("passes the whole Compose command to the shell and preserves child failure", () => {
|
||||
const root = path.resolve(__dirname, "../../../..");
|
||||
const compose = YAML.parse(fs.readFileSync(path.join(root, "deploy/trialrun/docker-compose.yml"), "utf8"));
|
||||
const directory = fs.mkdtempSync(path.join(os.tmpdir(), "hyb-seed-argv-"));
|
||||
try {
|
||||
fs.writeFileSync(path.join(directory, "npm"), '#!/bin/sh\n[ "$1" = run ] && [ "$2" = deploy:init ] && [ "$DEPLOY_DATA_MODE" = existing ] || exit 99\nprintf ENTRY_REACHED\nexit 17\n', { mode: 0o700 });
|
||||
const command = compose.services.init.command.map((argument) => argument.replace("/app/server", directory));
|
||||
const [executable, ...argumentsBeforeCommand] = compose.services.init.entrypoint;
|
||||
const result = spawnSync(executable, [...argumentsBeforeCommand, ...command], {
|
||||
env: { PATH: directory + ":" + process.env.PATH, DEPLOY_DATA_MODE: "existing" }, encoding: "utf8",
|
||||
});
|
||||
expect(result.stdout).toBe("ENTRY_REACHED");
|
||||
expect(result.status).toBe(17);
|
||||
} finally {
|
||||
fs.rmSync(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户