fix: 隔离既有发布的数据库容器生命周期(09-05-existing-deploy-db-lifecycle-isolation)
ci-e2e / e2e (push) Canceled after 0s
ci-minimal / check-minimal (push) Canceled after 0s

这个提交包含在:
2026-09-05 21:02:58 +08:00
父节点 05d2a3f78a
当前提交 f223bfb72f
共修改 11 个文件,包含 822 行新增和 7 行删除
@@ -0,0 +1,156 @@
const fs = require('node:fs');
const path = require('node:path');
const { spawnSync } = require('node:child_process');
const GLOBAL_VALUES = new Set(['-p', '--project-name', '-f', '--file', '--env-file', '--profile']);
const OPTION_VALUES = new Set(['--entrypoint', '-e', '--env', '--tail', '--format', '--type', '--timeout', '-t']);
const SWITCHES = new Set(['-d', '--detach', '--build', '--no-build', '--no-deps', '--rm', '-T', '-q', '--quiet', '-a', '--all', '-f', '--force', '--no-trunc']);
const LIFECYCLE = new Set(['up', 'down', 'run', 'start', 'stop', 'restart', 'rm', 'create', 'kill', 'pause', 'unpause']);
function parseCompose(argv) {
const args = [...argv];
if (args[0] === 'compose') args.shift();
const globals = {};
while (args[0]?.startsWith('-')) {
const option = args.shift();
if (!GLOBAL_VALUES.has(option) || !args.length) throw new Error('unknown_compose_global');
globals[option] = args.shift();
}
const operation = args.shift();
const targets = [];
let noDeps = false;
while (args.length) {
const argument = args.shift();
if (argument.startsWith('-')) {
if (OPTION_VALUES.has(argument)) {
if (!args.length) throw new Error('missing_option_value');
args.shift();
} else if (!SWITCHES.has(argument)) throw new Error('unknown_compose_option');
if (argument === '--no-deps') noDeps = true;
} else {
targets.push(argument);
if (['run', 'exec', 'port'].includes(operation)) break;
}
}
return { operation, targets, noDeps, globals, containerArgs: args };
}
function lifecycleServices(call, services) {
if (!LIFECYCLE.has(call.operation)) return [];
const selected = call.operation === 'down' || !call.targets.length ? Object.keys(services) : call.targets;
const result = new Set();
function visit(service) {
if (!Object.hasOwn(services, service)) throw new Error('unknown_compose_service');
if (result.has(service)) return;
result.add(service);
if (['up', 'run'].includes(call.operation) && !call.noDeps) {
const dependencies = services[service].depends_on ?? {};
for (const dependency of Array.isArray(dependencies) ? dependencies : Object.keys(dependencies)) visit(dependency);
}
}
selected.forEach(visit);
return [...result];
}
function main() {
const root = process.env.HYB_DEPLOYMENT_FIXTURE_ROOT;
if (!root || !path.basename(root).startsWith('hyb-db-lifecycle-') || path.dirname(process.argv[1]) !== path.join(root, 'bin')) {
throw new Error('fixture_only');
}
const config = JSON.parse(fs.readFileSync(path.join(root, 'config.json'), 'utf8'));
const command = path.basename(process.argv[1]);
const args = process.argv.slice(2);
const event = { command, args };
if (command === 'docker-compose' || (command === 'docker' && args[0] === 'compose')) {
event.compose = parseCompose(args);
event.lifecycle = lifecycleServices(event.compose, config.services);
}
fs.appendFileSync(path.join(root, 'trace.jsonl'), JSON.stringify(event) + '\n');
if (event.compose && config.driftAfter === event.compose.operation + ':' + event.compose.targets.join()) {
fs.writeFileSync(path.join(root, 'health-drift'), '1');
}
if (command === 'timeout') {
const child = spawnSync('/usr/bin/timeout', args, { env: process.env, stdio: 'inherit' });
process.exit(child.status ?? (child.signal === 'SIGKILL' ? 137 : 99));
}
if (command === 'sleep') return;
if (command === 'curl') {
if (args.join(' ') !== '-fsS http://127.0.0.1:31001/ready') throw new Error('unexpected_network_request');
console.log(JSON.stringify({ data: { buildSha: '123456789abc' } }));
return;
}
if (command === 'docker' && args[0] === 'inspect') {
if (args.at(-1) === 'e'.repeat(64) && args.includes('{{.Config.Image}}')) {
console.log('synthetic-project-worker:latest');
return;
}
if (args.at(-1) !== 'd'.repeat(64)) throw new Error('unexpected_container');
if (config.health === 'inspect_error') process.exit(18);
const counterFile = path.join(root, 'inspect-count');
const count = fs.existsSync(counterFile) ? Number(fs.readFileSync(counterFile, 'utf8')) : 0;
fs.writeFileSync(counterFile, String(count + 1));
if (config.health === 'query_hang') {
setTimeout(() => {}, 20000);
return;
}
const states = { stopped: 'exited|healthy', unhealthy: 'running|unhealthy', no_health: 'running|missing', starting: 'running|starting' };
const state = fs.existsSync(path.join(root, 'health-drift')) ? 'running|unhealthy' : config.health === 'warming' && count === 0 ? 'running|starting' : states[config.health] ?? 'running|healthy';
console.log(state);
return;
}
if (command === 'docker' && args.join(' ') === 'image inspect synthetic-project-worker:latest') process.exit(config.workerTagPresent ? 0 : 1);
const call = event.compose;
if (!call) throw new Error('forbidden_external_command');
if (call.operation === 'version') {
process.exit(command === 'docker' && config.engine === 'v1' ? 1 : 0);
}
if (call.globals['-p'] !== 'synthetic-project') throw new Error('wrong_compose_project');
if (call.noDeps && config.unsupportedIsolation) process.exit(2);
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')) {
throw new Error('unexpected_init_command');
}
if (config.initFails) process.exit(23);
}
return;
}
if (call.operation === 'ps') {
if (!call.targets.length) return;
if (call.targets.join() === 'worker') {
if (config.workerImagePresent) console.log('e'.repeat(64));
return;
}
if (call.targets.join() !== 'db') throw new Error('unexpected_ps_service');
if (config.health === 'query_error') process.exit(17);
if (config.health === 'missing' && !fs.existsSync(path.join(root, 'db-started'))) return;
console.log(config.health === 'multiple' ? 'd'.repeat(64) + '\n' + 'e'.repeat(64) : 'd'.repeat(64));
return;
}
if (call.operation === 'port' && call.targets.join() === 'server' && call.containerArgs.join() === '3000') {
console.log('127.0.0.1:31001');
return;
}
if (call.operation === 'config') return;
if (call.operation === 'build' && ['init', 'server', 'worker'].includes(call.targets.join())) return;
if (call.operation === 'logs' && call.targets.join() === 'server') return;
if (call.operation === 'exec' && call.targets.join() === 'server') {
const expected = ['sh -lc cd /app/server && npx prisma migrate status', 'node src/scripts/smoke-external-deps.js', 'node src/scripts/smoke-trialrun-runtime.js'];
if (expected.includes(call.containerArgs.join(' '))) return;
if (call.containerArgs[0] === 'node' && call.containerArgs[1] === '-e' && call.containerArgs[2]?.includes('EXPECTED_BUILD_SHA')) {
const input = JSON.parse(fs.readFileSync(0, 'utf8'));
if (input.data?.buildSha !== '123456789abc') throw new Error('wrong_ready_build');
return;
}
}
throw new Error('unknown_fake_command');
}
module.exports = { parseCompose, lifecycleServices };
if (require.main === module) {
try { main(); } catch (error) {
console.error('[deployment-fixture] ' + (/^[a-z_]+$/.test(error.message) ? error.message : 'unexpected_failure'));
process.exitCode = 97;
}
}
@@ -0,0 +1,264 @@
const { execFile } = require('node:child_process');
const { mkdtemp, mkdir, writeFile, readFile, chmod, symlink, rm } = require('node:fs/promises');
const { promisify } = require('node:util');
const os = require('node:os');
const path = require('node:path');
const YAML = require('yaml');
const { parseCompose, lifecycleServices } = require('../fixtures/deployment-command-fake.cjs');
const execute = promisify(execFile);
const ROOT = path.resolve(__dirname, '../../../..');
const DEPLOY = path.join(ROOT, 'deploy/trialrun/deploy.sh');
const COMPOSE = path.join(ROOT, 'deploy/trialrun/docker-compose.yml');
const FAKE = path.join(__dirname, '../fixtures/deployment-command-fake.cjs');
const ORIGINAL_PATH = process.env.PATH;
function remoteSection(source) {
const sections = [...source.matchAll(/<<'REMOTE'\n([\s\S]*?)\nREMOTE\n/g)];
expect(sections).toHaveLength(1);
return sections[0][1];
}
function replaceOnce(source, before, after) {
expect(source.split(before)).toHaveLength(2);
return source.replace(before, after);
}
function assertNoDatabaseLifecycle(trace) {
const violations = trace.filter(event => event.lifecycle?.includes('db'));
if (violations.length) throw new Error('database_lifecycle_forbidden:' + violations.map(event => event.compose.operation).join(','));
}
describe('database lifecycle isolation in the actual deployment remote script', () => {
let fixtureRoot;
let source;
let services;
beforeEach(async () => {
fixtureRoot = await mkdtemp(path.join(os.tmpdir(), 'hyb-db-lifecycle-'));
source = await readFile(DEPLOY, 'utf8');
services = YAML.parse(await readFile(COMPOSE, 'utf8')).services;
const bin = path.join(fixtureRoot, 'bin');
await mkdir(bin);
const fakeSource = '#!' + process.execPath + '\n' + await readFile(FAKE, 'utf8');
for (const command of ['docker', 'docker-compose', 'curl', 'timeout', 'sleep', 'ssh', 'rsync', 'node', 'git', 'sudo']) {
const file = path.join(bin, command);
await writeFile(file, fakeSource);
await chmod(file, 0o700);
}
for (const command of ['bash', 'mkdir', 'install', 'chmod', 'seq']) await symlink('/usr/bin/' + command, path.join(bin, command));
await mkdir(path.join(fixtureRoot, 'app'));
await mkdir(path.join(fixtureRoot, 'storage'));
await mkdir(path.join(fixtureRoot, 'postgres'));
await writeFile(path.join(fixtureRoot, 'postgres/PG_VERSION'), '16\n');
await writeFile(path.join(fixtureRoot, 'env'), 'APP_ORIGIN=https://example.invalid\n', { mode: 0o600 });
});
afterEach(async () => {
expect(process.env.PATH).toBe(ORIGINAL_PATH);
await rm(fixtureRoot, { recursive: true, force: true });
});
async function run({ mode = 'existing', worker = false, minio = false, engine = 'v2', health = 'healthy', initFails = false, unsupportedIsolation = false, driftAfter = '', workerImagePresent = true, workerTagPresent = true, script = remoteSection(source) } = {}) {
await writeFile(path.join(fixtureRoot, 'config.json'), JSON.stringify({ services, engine, health, initFails, unsupportedIsolation, driftAfter, workerImagePresent, workerTagPresent }));
await writeFile(path.join(fixtureRoot, 'trace.jsonl'), '');
const scriptPath = path.join(fixtureRoot, 'remote.sh');
await writeFile(scriptPath, script);
const env = {
PATH: path.join(fixtureRoot, 'bin'), HOME: fixtureRoot,
DOCKER_HOST: 'unix://' + path.join(fixtureRoot, 'nonexistent-docker.sock'), DOCKER_CONFIG: path.join(fixtureRoot, 'docker-config'),
HYB_DEPLOYMENT_FIXTURE_ROOT: fixtureRoot,
REMOTE_DIR: path.join(fixtureRoot, 'app'), REMOTE_ENV_FILE: path.join(fixtureRoot, 'env'),
POSTGRES_DATA_DIR: path.join(fixtureRoot, 'postgres'), STORAGE_HOST_DIR: path.join(fixtureRoot, 'storage'),
COMPOSE_PROJECT_NAME: 'synthetic-project', COMPOSE_FILE: 'compose.yml',
DEPLOY_DATA_MODE: mode, ENABLE_WORKER: worker ? '1' : '0', ENABLE_MINIO: minio ? '1' : '0',
DEPLOY_STATUS_OUTPUT_MODE: 'quiet', BUILD_SHA: '123456789abc'.padEnd(40, '0'), VITE_BUILD_SHA: '123456789abc',
};
let result;
try {
result = { ...await execute('/usr/bin/bash', [scriptPath], { env, timeout: 15000 }), code: 0 };
} catch (error) {
result = { code: error.code, stdout: error.stdout, stderr: error.stderr, killed: error.killed };
}
const trace = (await readFile(path.join(fixtureRoot, 'trace.jsonl'), 'utf8')).trim().split('\n').filter(Boolean).map(line => JSON.parse(line));
return { ...result, trace };
}
function assertCompleted(result, worker) {
expect(result.code, result.stdout + result.stderr).toBe(0);
const stages = result.trace.filter(event => ['run', 'up'].includes(event.compose?.operation));
expect(stages.some(event => event.compose.operation === 'run' && event.compose.targets.join() === 'init')).toBe(true);
expect(stages.some(event => event.compose.operation === 'up' && event.compose.targets.join() === 'server')).toBe(true);
expect(stages.some(event => event.compose.operation === 'up' && event.compose.targets.join() === 'worker')).toBe(worker);
expect(result.stdout).toContain('4.10');
}
it('existing release never operates on db while reaching init and the application', async () => {
const result = await run();
assertCompleted(result, false);
assertNoDatabaseLifecycle(result.trace);
});
for (const engine of ['v1', 'v2']) {
for (const mode of ['existing', 'initialize']) {
for (const worker of [false, true]) {
for (const minio of [false, true]) {
it(`${mode}/${engine}/worker=${worker}/minio=${minio} preserves the service boundary`, async () => {
const result = await run({ engine, mode, worker, minio });
assertCompleted(result, worker);
const databaseEvents = result.trace.filter(event => event.lifecycle?.includes('db'));
if (mode === 'existing') assertNoDatabaseLifecycle(result.trace);
else {
expect(databaseEvents).toHaveLength(1);
expect(databaseEvents[0].compose).toMatchObject({ operation: 'up', targets: ['db'] });
}
expect(result.trace.some(event => event.compose?.targets.join() === 'minio,minio_init')).toBe(minio);
const healthIndex = result.trace.findIndex(event => event.command === 'docker' && event.args[0] === 'inspect');
const initIndex = result.trace.findIndex(event => event.compose?.operation === 'run');
expect(healthIndex).toBeGreaterThan(-1);
expect(healthIndex).toBeLessThan(initIndex);
const deadline = result.trace.find(event => event.command === 'timeout');
expect(deadline.args.slice(0, 2)).toEqual(['--signal=KILL', '120']);
});
}
}
}
}
it.each(['missing', 'stopped', 'unhealthy', 'no_health', 'multiple', 'query_error', 'inspect_error'])('existing %s database fails closed without init or application switching', async health => {
const result = await run({ health });
expect(result.code).not.toBe(0);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.some(event => ['run', 'up'].includes(event.compose?.operation))).toBe(false);
});
it.each(['', 'unknown'])('invalid mode %s is rejected before container commands', async mode => {
const result = await run({ mode });
expect(result.code).not.toBe(0);
expect(result.trace).toEqual([]);
});
it.each(['existing', 'initialize'])('missing deadline tooling stops %s before lifecycle operations', async mode => {
await rm(path.join(fixtureRoot, 'bin/timeout'));
const result = await run({ mode });
expect(result.code).toBe(1);
expect(result.stderr).toContain('db_health_timeout_unavailable');
expect(result.trace.some(event => event.lifecycle?.length)).toBe(false);
});
it('waits for starting health before init without starting db', async () => {
const result = await run({ health: 'warming' });
assertCompleted(result, false);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.args[0] === 'inspect').length).toBeGreaterThanOrEqual(2);
expect(result.trace.find(event => event.command === 'sleep').args).toEqual(['2']);
});
it('initialize starts a missing database before health and init', async () => {
const result = await run({ mode: 'initialize', health: 'missing' });
assertCompleted(result, false);
const start = result.trace.findIndex(event => event.lifecycle?.includes('db'));
const health = result.trace.findIndex(event => event.args[0] === 'inspect');
expect(start).toBeLessThan(health);
});
it.each(['starting', 'query_hang'])('the real timeout terminates %s health and prevents init', async health => {
const shortened = replaceOnce(remoteSection(source), 'timeout --signal=KILL 120 ', 'timeout --signal=KILL 1 ');
const result = await run({ health, script: shortened });
expect(result.code).toBe(1);
expect(result.killed).not.toBe(true);
expect(result.stdout + result.stderr).toContain('db_health_timeout');
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.some(event => ['run', 'up'].includes(event.compose?.operation))).toBe(false);
});
it('init failure prevents server and worker switching', async () => {
const result = await run({ initFails: true, worker: true });
expect(result.code).toBe(23);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.compose?.operation === 'up')).toEqual([]);
});
it('unsupported isolation fails instead of retrying without protection', async () => {
const result = await run({ unsupportedIsolation: true });
expect(result.code).toBe(2);
assertNoDatabaseLifecycle(result.trace);
expect(result.trace.filter(event => event.compose?.operation === 'run')).toHaveLength(1);
expect(result.trace.filter(event => event.compose?.operation === 'up')).toEqual([]);
});
it.each([
['build:init', 'run:init'], ['run:init', 'build:server'], ['build:server', 'up:server'],
['up:server', 'up:worker'], ['build:worker', 'up:worker'],
])('health drift after %s prevents %s without managing db', async (driftAfter, forbiddenStage) => {
const result = await run({ driftAfter, worker: true, workerImagePresent: false });
expect(result.code).toBe(1);
expect(result.stdout).toContain('db_not_healthy');
assertNoDatabaseLifecycle(result.trace);
const stages = result.trace.filter(event => event.compose).map(event => event.compose.operation + ':' + event.compose.targets.join());
expect(stages).toContain(driftAfter);
expect(stages).not.toContain(forbiddenStage);
});
it.each([true, false])('worker image present=%s keeps build outside the guarded switch', async workerImagePresent => {
const result = await run({ worker: true, workerImagePresent });
assertCompleted(result, true);
assertNoDatabaseLifecycle(result.trace);
const build = result.trace.findIndex(event => event.compose?.operation === 'build' && event.compose.targets.join() === 'worker');
expect(build >= 0).toBe(!workerImagePresent);
for (const service of ['server', 'worker']) {
const start = result.trace.findIndex(event => event.compose?.operation === 'up' && event.compose.targets.join() === service);
expect(result.trace[start - 1].args[0]).toBe('inspect');
expect(result.trace[start].args).toContain('--no-build');
}
});
it('a worker container with a missing image tag is rebuilt before switching server', async () => {
const result = await run({ worker: true, workerImagePresent: true, workerTagPresent: false });
assertCompleted(result, true);
assertNoDatabaseLifecycle(result.trace);
const build = result.trace.findIndex(event => event.compose?.operation === 'build' && event.compose.targets.join() === 'worker');
const serverSwitch = result.trace.findIndex(event => event.compose?.operation === 'up' && event.compose.targets.join() === 'server');
expect(build).toBeGreaterThan(-1);
expect(build).toBeLessThan(serverSwitch);
expect(result.trace.some(event => event.command === 'docker' && event.args[0] === 'image')).toBe(true);
});
it('the service analyzer rejects explicit, global and implicit database operations', () => {
for (const args of [['up', '-d', 'db'], ['down'], ['up', '-d'], ['restart', 'db'], ['rm', '-f', 'db'], ['run', '--rm', '-T', '--entrypoint', 'sh', 'init', '-lc', 'echo db'], ['up', '-d', '--build', 'server'], ['up', '-d', 'worker']]) {
expect(lifecycleServices(parseCompose(args), services)).toContain('db');
}
expect(lifecycleServices(parseCompose(['run', '--no-deps', '--rm', '--entrypoint', 'sh', 'init', '-lc', 'echo db']), services)).toEqual(['init']);
expect(lifecycleServices(parseCompose(['exec', '-T', 'server', 'node', 'db']), services)).toEqual([]);
expect(() => parseCompose(['up', '--unknown', 'server'])).toThrow('unknown_compose_option');
});
it.each(['db', 'init', 'server', 'worker'])('real-source mutation %s produces a forbidden database event', async service => {
let mutated = remoteSection(source);
if (service === 'db') mutated = replaceOnce(mutated, 'if [[ "$DEPLOY_DATA_MODE" == "initialize" ]]; then\n "${compose_cmd[@]}" up -d db\nfi', '"${compose_cmd[@]}" up -d db');
else {
const lines = mutated.split('\n');
const matches = lines.filter(line => line.includes('--no-deps') && (service === 'init' ? line.includes(' init -lc ') : line.endsWith(' ' + service)));
expect(matches).toHaveLength(1);
mutated = replaceOnce(mutated, matches[0], matches[0].replace('--no-deps ', ''));
}
const result = await run({ script: mutated, worker: true });
assertCompleted(result, true);
expect(() => assertNoDatabaseLifecycle(result.trace)).toThrow('database_lifecycle_forbidden:');
});
it('unknown external calls cannot escape the private fake PATH', async () => {
const result = await run({ script: 'set -e\nssh unexpected.invalid\n' });
expect(result.code).toBe(97);
expect(result.trace).toEqual([{ command: 'ssh', args: ['unexpected.invalid'] }]);
});
it('the existing wrapper pins mode regardless of an inherited initialize value', async () => {
const wrapper = await readFile(path.join(ROOT, 'deploy/trialrun/deploy-existing.sh'), 'utf8');
const invocations = [...wrapper.matchAll(/DEPLOY_SERVER="\$DEPLOY_SERVER" \\\nREMOTE_DIR="\$\{contract\[REMOTE_DIR\]\}"[\s\S]*?bash "\$ROOT_DIR\/deploy\/trialrun\/deploy\.sh"/g)];
expect(invocations).toHaveLength(1);
const body = invocations[0][0].replace('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"', '/usr/bin/printenv DEPLOY_DATA_MODE');
const result = await execute('/usr/bin/bash', ['-c', 'declare -A contract=();\n' + body], { env: { ROOT_DIR: fixtureRoot, DEPLOY_DATA_MODE: 'initialize' } });
expect(result.stdout.trim()).toBe('existing');
});
});