import { createServer } from 'node:http'; import { mkdir, readFile, readlink, rm, symlink, writeFile } from 'node:fs/promises'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { afterEach, describe, expect, it } from 'vitest'; import { bootstrapRuntime, finalizePendingFailure, markPendingHealthCheck, normalizePending, pruneRuntimeReleases, readPending, reconcilePending, resolveSymlinkPointer, switchPointers, waitForHealth, } from './docker-runner.mjs'; const roots: string[] = []; function runtimeArchitecture(): 'amd64' | 'arm64' { return process.arch === 'arm64' ? 'arm64' : 'amd64'; } function runtimeNodeMajor(): number { return Number.parseInt(process.versions.node.split('.')[0] || '0', 10); } function releaseManifest(version: string, entrypoint = 'server.js') { return { schemaVersion: 1, version, channel: 'stable', platform: 'linux', arch: runtimeArchitecture(), nodeMajor: runtimeNodeMajor(), entrypoint, migrationEntrypoint: 'migrate.js', }; } async function tempRoot(): Promise { const root = join(tmpdir(), `metapi-runner-${Date.now()}-${Math.random().toString(16).slice(2)}`); roots.push(root); await mkdir(root, { recursive: true }); return root; } afterEach(async () => { while (roots.length) await rm(roots.pop()!, { recursive: true, force: true }); }); describe('docker runner', () => { it('does not remove an existing update lock during bootstrap startup', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); const runtime = join(root, 'runtime'); await mkdir(bootstrap, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); await mkdir(runtime, { recursive: true }); const lockPath = join(runtime, '.update.lock'); await writeFile(lockPath, JSON.stringify({ taskId: 'active-update', pid: process.pid, token: 'active-token' })); bootstrapRuntime(runtime, bootstrap); await expect(readFile(lockPath, 'utf8')).resolves.toContain('active-update'); const runnerSource = await readFile(new URL('./docker-runner.mjs', import.meta.url), 'utf8'); expect(runnerSource).not.toMatch(/rmSync\(\s*join\(runtime,\s*['"]\.update\.lock['"]\s*\)/); }); it('bootstraps an empty runtime and replaces an invalid existing release', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); const runtime = join(root, 'runtime'); await mkdir(bootstrap, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); const first = bootstrapRuntime(runtime, bootstrap); expect(first).toBe(join(runtime, 'releases', '1.0.0')); expect(await readlink(join(runtime, 'current'))).toBe('releases/1.0.0'); await rm(join(runtime, 'releases', '1.0.0'), { recursive: true, force: true }); await mkdir(join(runtime, 'releases', '1.0.0'), { recursive: true }); await writeFile(join(runtime, 'releases', '1.0.0', 'stale.txt'), 'remove me'); const second = bootstrapRuntime(runtime, bootstrap); expect(second).toBe(join(runtime, 'releases', '1.0.0')); expect(await readFile(join(second, 'server.js'), 'utf8')).toBe('ok'); await expect(readFile(join(second, 'stale.txt'), 'utf8')).rejects.toThrow(); expect(resolveSymlinkPointer(runtime, 'current')).toBe(second); }); it('keeps pointer targets inside releases and switches current/previous atomically', async () => { const root = await tempRoot(); await mkdir(join(root, 'releases', '1.0.0'), { recursive: true }); await mkdir(join(root, 'releases', '1.1.0'), { recursive: true }); await writeFile(join(root, 'releases', '1.0.0', 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(root, 'releases', '1.1.0', 'release.json'), JSON.stringify(releaseManifest('1.1.0'))); await writeFile(join(root, 'releases', '1.0.0', 'server.js'), 'ok'); await writeFile(join(root, 'releases', '1.0.0', 'migrate.js'), 'ok'); await writeFile(join(root, 'releases', '1.1.0', 'server.js'), 'ok'); await writeFile(join(root, 'releases', '1.1.0', 'migrate.js'), 'ok'); switchPointers(root, join(root, 'releases', '1.0.0')); switchPointers(root, join(root, 'releases', '1.1.0'), join(root, 'releases', '1.0.0')); expect(await readlink(join(root, 'current'))).toBe('releases/1.1.0'); expect(await readlink(join(root, 'previous'))).toBe('releases/1.0.0'); expect(resolveSymlinkPointer(root, 'current')).toBe(join(root, 'releases', '1.1.0')); }); it('promotes a newer image bootstrap without replacing a newer runtime release', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); const runtime = join(root, 'runtime'); await mkdir(bootstrap, { recursive: true }); await mkdir(join(runtime, 'releases', '1.0.0'), { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.1.0'))); await writeFile(join(bootstrap, 'server.js'), 'new'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); await writeFile(join(runtime, 'releases', '1.0.0', 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(runtime, 'releases', '1.0.0', 'server.js'), 'old'); await writeFile(join(runtime, 'releases', '1.0.0', 'migrate.js'), 'ok'); await symlink('releases/1.0.0', join(runtime, 'current')); const selected = bootstrapRuntime(runtime, bootstrap); expect(selected).toBe(join(runtime, 'releases', '1.1.0')); expect(await readlink(join(runtime, 'current'))).toBe('releases/1.1.0'); expect(await readlink(join(runtime, 'previous'))).toBe('releases/1.0.0'); expect(JSON.parse(await readFile(join(runtime, 'pending.json'), 'utf8'))).toMatchObject({ targetVersion: '1.1.0', previousVersion: '1.0.0', reason: 'bootstrap', rollbackBudget: 1, }); }); it('drops malformed pending transactions and accepts only the complete schema', async () => { const root = await tempRoot(); await writeFile(join(root, 'pending.json'), '{not-json'); expect(readPending(root)).toBeNull(); await expect(readFile(join(root, 'pending.json'), 'utf8')).rejects.toThrow(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toMatchObject({ rollbackBudget: 1 }); expect(normalizePending({ taskId: 'task', targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '01.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: '1', reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 123, targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0+build.1', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 2, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); expect(normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0', previousVersion: null, phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: 123, updatedAt: '2026-09-04T00:00:00.000Z', })).toBeNull(); }); it('rejects truncated manifest numeric fields', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); await mkdir(bootstrap, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify({ ...releaseManifest('1.0.0'), nodeMajor: 22.9, })); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); expect(() => bootstrapRuntime(join(root, 'runtime'), bootstrap)).toThrow(/nodeMajor/); }); it('marks a pending transaction as health-check without losing rollback metadata', async () => { const root = await tempRoot(); const pending = normalizePending({ schemaVersion: 1, taskId: 'health-check-task', targetVersion: '1.2.0', previousVersion: '1.1.0', phase: 'restarting', rollbackBudget: 0, rollbackApplied: true, reason: 'manual-rollback', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', }); expect(pending).not.toBeNull(); await writeFile(join(root, 'pending.json'), JSON.stringify(pending)); const updated = markPendingHealthCheck(root, pending!); expect(updated).toMatchObject({ schemaVersion: 1, taskId: 'health-check-task', targetVersion: '1.2.0', previousVersion: '1.1.0', phase: 'health-check', rollbackBudget: 0, rollbackApplied: true, reason: 'manual-rollback', }); expect(Date.parse(updated.updatedAt)).toBeGreaterThanOrEqual(Date.parse(updated.createdAt)); expect(JSON.parse(await readFile(join(root, 'pending.json'), 'utf8'))).toMatchObject({ phase: 'health-check', rollbackBudget: 0, rollbackApplied: true, }); }); it('rejects marking a pending transaction when another task replaced it', async () => { const root = await tempRoot(); const original = normalizePending({ schemaVersion: 1, taskId: 'original-task', targetVersion: '1.2.0', previousVersion: '1.1.0', phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', }); const replacement = { ...original, taskId: 'replacement-task' }; await writeFile(join(root, 'pending.json'), JSON.stringify(replacement)); expect(() => markPendingHealthCheck(root, original!)).toThrow('pending update transaction changed during startup'); expect(JSON.parse(await readFile(join(root, 'pending.json'), 'utf8'))).toMatchObject({ taskId: 'replacement-task', phase: 'restarting', }); }); it('clears an exhausted pending transaction before the runner exits', async () => { const root = await tempRoot(); const current = join(root, 'releases', '1.2.0'); const previous = join(root, 'releases', '1.1.0'); for (const [releasePath, version] of [[current, '1.2.0'], [previous, '1.1.0']] as const) { await mkdir(releasePath, { recursive: true }); await writeFile(join(releasePath, 'release.json'), JSON.stringify(releaseManifest(version))); await writeFile(join(releasePath, 'server.js'), 'ok'); await writeFile(join(releasePath, 'migrate.js'), 'ok'); } await symlink('releases/1.2.0', join(root, 'current')); await symlink('releases/1.1.0', join(root, 'previous')); const pending = normalizePending({ schemaVersion: 1, taskId: 'exhausted-task', targetVersion: '1.1.0', previousVersion: '1.2.0', phase: 'restarting', rollbackBudget: 0, rollbackApplied: true, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', }); await writeFile(join(root, 'pending.json'), JSON.stringify(pending)); finalizePendingFailure(root, pending, 'previous release failed health check'); await expect(readFile(join(root, 'pending.json'), 'utf8')).rejects.toThrow(); expect(JSON.parse(await readFile(join(root, 'state.json'), 'utf8'))).toMatchObject({ updateState: 'failed', currentVersion: '1.2.0', previousVersion: '1.1.0', taskId: 'exhausted-task', restartPending: false, lastError: 'previous release failed health check', }); }); it('removes malformed pending directories and dangling links', async () => { const root = await tempRoot(); await mkdir(join(root, 'pending.json'), { recursive: true }); await writeFile(join(root, 'pending.json', 'stale'), 'stale'); expect(readPending(root)).toBeNull(); await expect(readFile(join(root, 'pending.json', 'stale'), 'utf8')).rejects.toThrow(); await symlink(join(root, 'missing-target'), join(root, 'pending.json')); expect(readPending(root)).toBeNull(); await expect(readlink(join(root, 'pending.json'))).rejects.toThrow(); }); it('accepts only a ready health response for the requested version', async () => { const server = createServer((request, response) => { if (request.url === '/healthz') { response.setHeader('content-type', 'application/json'); response.end(JSON.stringify({ status: 'ok', ready: true, version: '1.1.0' })); return; } response.statusCode = 404; response.end(); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); const address = server.address(); const port = typeof address === 'object' && address ? address.port : 0; try { await expect(waitForHealth('1.1.0', port, 500, 10)).resolves.toMatchObject({ ready: true, version: '1.1.0' }); await expect(waitForHealth('1.2.0', port, 30, 10)).rejects.toThrow('health version mismatch'); } finally { await new Promise((resolve) => server.close(() => resolve())); } }); it('does not accept a pending transaction for an unrelated current release', async () => { const root = await tempRoot(); const current = join(root, 'releases', '1.0.0'); await mkdir(current, { recursive: true }); await writeFile(join(current, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(current, 'server.js'), 'ok'); await writeFile(join(current, 'migrate.js'), 'ok'); await symlink('releases/1.0.0', join(root, 'current')); const result = reconcilePending(root, current, normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.2.0', previousVersion: '0.9.0', phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })); expect(result.invalid).toBe(true); expect(result.pending).toBeNull(); }); it('completes a persisted rollback transaction after interruption before pointer switch', async () => { const root = await tempRoot(); const candidate = join(root, 'releases', '1.2.0'); const previous = join(root, 'releases', '1.1.0'); await mkdir(candidate, { recursive: true }); await mkdir(previous, { recursive: true }); for (const [releasePath, version] of [[candidate, '1.2.0'], [previous, '1.1.0']] as const) { await writeFile(join(releasePath, 'release.json'), JSON.stringify(releaseManifest(version))); await writeFile(join(releasePath, 'server.js'), 'ok'); await writeFile(join(releasePath, 'migrate.js'), 'ok'); } await symlink('releases/1.2.0', join(root, 'current')); await symlink('releases/1.1.0', join(root, 'previous')); const pending = normalizePending({ schemaVersion: 1, taskId: 'rollback-task', targetVersion: '1.1.0', previousVersion: '1.2.0', phase: 'restarting', rollbackBudget: 0, rollbackApplied: true, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', }); const result = reconcilePending(root, candidate, pending); expect(result.pending).toMatchObject({ targetVersion: '1.1.0', rollbackBudget: 0, rollbackApplied: true }); expect(result.current).toBe(previous); expect(await readlink(join(root, 'current'))).toBe('releases/1.1.0'); expect(await readlink(join(root, 'previous'))).toBe('releases/1.2.0'); }); it('turns an unreadable current manifest into an invalid transaction instead of crashing', async () => { const root = await tempRoot(); const current = join(root, 'releases', '1.0.0'); await mkdir(current, { recursive: true }); await writeFile(join(current, 'release.json'), '{broken'); const result = reconcilePending(root, current, normalizePending({ schemaVersion: 1, taskId: 'task', targetVersion: '1.1.0', previousVersion: '1.0.0', phase: 'restarting', rollbackBudget: 1, reason: 'install', createdAt: '2026-09-04T00:00:00.000Z', updatedAt: '2026-09-04T00:00:00.000Z', })); expect(result.invalid).toBe(true); expect(result.pending).toBeNull(); expect(result.error).toContain('release manifest'); }); it('rejects a runtime releases symlink before bootstrap writes outside the runtime', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); const outside = join(root, 'outside'); await mkdir(bootstrap, { recursive: true }); await mkdir(outside, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); await mkdir(root, { recursive: true }); await symlink(outside, join(root, 'releases')); expect(() => bootstrapRuntime(root, bootstrap)).toThrow('runtime releases directory must not be a symbolic link'); }); it('rejects a non-canonical current pointer', async () => { const root = await tempRoot(); const release = join(root, 'releases', '1.0.0'); await mkdir(release, { recursive: true }); await writeFile(join(release, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(release, 'server.js'), 'ok'); await writeFile(join(release, 'migrate.js'), 'ok'); await symlink('releases/./1.0.0', join(root, 'current')); expect(resolveSymlinkPointer(root, 'current')).toBeNull(); }); it('rejects existing invalid current or previous pointers before bootstrap can overwrite them', async () => { for (const pointer of ['current', 'previous'] as const) { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); await mkdir(bootstrap, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); await mkdir(join(root, 'releases'), { recursive: true }); await mkdir(join(root, 'staging'), { recursive: true }); const pointerPath = join(root, pointer); await writeFile(pointerPath, 'do not overwrite'); expect(() => bootstrapRuntime(root, bootstrap)).toThrow(`runtime ${pointer} pointer is invalid or unsafe`); await expect(readFile(pointerPath, 'utf8')).resolves.toBe('do not overwrite'); } }); it('rejects dangling runtime pointers before bootstrap can repair them', async () => { const root = await tempRoot(); const bootstrap = join(root, 'bootstrap'); await mkdir(bootstrap, { recursive: true }); await writeFile(join(bootstrap, 'release.json'), JSON.stringify(releaseManifest('1.0.0'))); await writeFile(join(bootstrap, 'server.js'), 'ok'); await writeFile(join(bootstrap, 'migrate.js'), 'ok'); await mkdir(join(root, 'releases'), { recursive: true }); await mkdir(join(root, 'staging'), { recursive: true }); await symlink('releases/missing', join(root, 'current')); expect(() => bootstrapRuntime(root, bootstrap)).toThrow('runtime current pointer is invalid or unsafe'); await expect(readlink(join(root, 'current'))).resolves.toBe('releases/missing'); }); it('prunes unprotected old releases while retaining current and previous', async () => { const root = await tempRoot(); for (const version of ['1.0.0', '1.1.0', '1.2.0', '1.3.0']) { const release = join(root, 'releases', version); await mkdir(release, { recursive: true }); await writeFile(join(release, 'release.json'), JSON.stringify(releaseManifest(version))); await writeFile(join(release, 'server.js'), 'ok'); await writeFile(join(release, 'migrate.js'), 'ok'); } await symlink('releases/1.3.0', join(root, 'current')); await symlink('releases/1.2.0', join(root, 'previous')); expect(pruneRuntimeReleases(root)).toEqual(['1.1.0', '1.0.0']); await expect(readFile(join(root, 'releases', '1.2.0', 'server.js'), 'utf8')).resolves.toBe('ok'); await expect(readFile(join(root, 'releases', '1.3.0', 'server.js'), 'utf8')).resolves.toBe('ok'); await expect(readFile(join(root, 'releases', '1.1.0', 'server.js'), 'utf8')).rejects.toThrow(); }); });