mirror of
https://github.com/cita-777/metapi.git
synced 2026-10-31 19:47:31 +08:00
531 lines
22 KiB
TypeScript
531 lines
22 KiB
TypeScript
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<string> {
|
|
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<void>((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<void>((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();
|
|
});
|
|
});
|