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, startCommandServer } = 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-client.py'); 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; let closeCommands; 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 = 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 }); closeCommands = await startCommandServer(fixtureRoot); }); afterEach(async () => { expect(process.env.PATH).toBe(ORIGINAL_PATH); await closeCommands?.(); closeCommands = undefined; 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, mode, 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 private transport preserves empty, quoted and multiline command arguments', async () => { const result = await run({ script: "set -e\nssh '' 'space 中文' $'first\\nsecond'\n" }); expect(result.code).toBe(97); expect(result.trace).toEqual([{ command: 'ssh', args: ['', 'space 中文', 'first\nsecond'] }]); }); it('the private transport preserves ready stdin validation and failure status', async () => { const result = await run({ script: `set -e\nprintf '%s' '{"data":{"buildSha":"wrong"}}' | docker compose -p synthetic-project exec -T server node -e 'EXPECTED_BUILD_SHA'\n` }); expect(result.code).toBe(97); expect(result.stderr).toContain('wrong_ready_build'); expect(result.trace).toHaveLength(1); }); it('an unavailable private transport fails instead of invoking real commands', async () => { await closeCommands(); closeCommands = undefined; const result = await run({ script: 'set -e\ndocker compose version\n' }); expect(result.code).toBe(97); expect(result.stderr).toContain('transport_failed'); expect(result.trace).toEqual([]); }); 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'); }); });