Files
electric-cherry/tests/unit/provisioning-evidence.test.ts
T

285 lines
20 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { join } from 'node:path';
import { newFixture, type Fixture } from '../helpers/fixture.js';
import { gameEvent } from '../helpers/telemetry.js';
vi.mock('../../src/lib/server/integrations/komodo.js', () => ({
komodoRestart: vi.fn(), komodoRuntimeAction: vi.fn(), komodoInspectHealthy: vi.fn(),
komodoCreateDeployment: vi.fn(), komodoDeploy: vi.fn(), komodoDestroyDeployment: vi.fn(), komodoDeleteDeployment: vi.fn(),
komodoEnsureTag: vi.fn(), komodoUpdateTagsOnResource: vi.fn()
}));
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconGetPlayerCount: vi.fn(), rconStatusResult: vi.fn() }));
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
let fixture: Fixture;
const server = 't5_zombie_theater_1';
const status = (map = 'zombie_theater') => ({ ok: true as const, endpoint: { host: 'test', port: 28960 }, rawReply: '',
status: { players: 0, map, gametype: 'zom', roster: [] } });
beforeEach(async () => {
vi.resetModules(); vi.resetAllMocks();
vi.useFakeTimers({ toFake: ['Date'] });
vi.setSystemTime(new Date('2026-09-28T12:00:00Z'));
fixture = await newFixture([server]);
for (const [key, value] of Object.entries({ KOMODO_ADDRESS: 'http://komodo.test', KOMODO_API_KEY: 'test', KOMODO_API_SECRET: 'test',
KOMODO_TARGET_SERVER: 'host', RCON_PASSWORD: 'test', DEPLOYMENT_CREATE_CONCURRENCY: '1', DEPLOYMENT_RESTART_CONCURRENCY: '1',
SERVER_PORT_MIN: '28960', SERVER_PORT_MAX: '28970' })) vi.stubEnv(key, value);
const komodo = await import('../../src/lib/server/integrations/komodo.js');
for (const name of ['komodoRestart', 'komodoRuntimeAction', 'komodoCreateDeployment', 'komodoDeploy', 'komodoDestroyDeployment', 'komodoDeleteDeployment'] as const) {
vi.mocked(komodo[name]).mockResolvedValue({ ok: true });
}
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: true, state: 'running' });
vi.mocked(komodo.komodoEnsureTag).mockImplementation(async name => ({ ok: true, id: name }));
vi.mocked(komodo.komodoUpdateTagsOnResource).mockResolvedValue({ ok: true });
const rcon = await import('../../src/lib/server/integrations/rcon.js');
vi.mocked(rcon.rconGetPlayerCount).mockResolvedValue(0);
vi.mocked(rcon.rconStatusResult).mockResolvedValue(status());
});
afterEach(async () => { await fixture.cleanup(); vi.useRealTimers(); vi.unstubAllEnvs(); });
async function register() {
const { db } = await import('../../src/lib/server/db/index.js');
db().prepare(`INSERT INTO servers (name,komodo_type,komodo_name,game,map,game_mode,map_slug,game_port,host_dir)
VALUES (?, 'deployment', ?, 't5','zombie_theater','zombie','theater',28960,?)`).run(server, server, join(fixture.t5, server));
const { saveServerKey } = await import('../../src/lib/server/fleet/server-keys.js');
saveServerKey(server, 'saved-key');
}
async function detail(id: string) {
const { getDeployment } = await import('../../src/lib/server/provisioning/history.js');
return getDeployment(id)!;
}
async function telemetry(players: number, map = 'zombie_theater') {
const { db } = await import('../../src/lib/server/db/index.js');
const receivedAt = new Date().toISOString();
db().prepare(`INSERT INTO server_telemetry (server,round,players,received_at,payload) VALUES (?,1,?,?,?)
ON CONFLICT(server) DO UPDATE SET players=excluded.players,received_at=excluded.received_at,payload=excluded.payload`)
.run(server, players, receivedAt, JSON.stringify({ ...gameEvent('heartbeat', 1, players, server, map), receivedAt }));
return receivedAt;
}
describe('provisioning decision evidence', () => {
it('distinguishes queued recipes, attempted files, transient map checks, and telemetry completion without replaying startup', async () => {
const { queueCreateServer, processCreations } = await import('../../src/lib/server/provisioning/instances.js');
const { komodoCreateDeployment, komodoDeploy, komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
const job = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'saved-key' });
if (!job.ok) throw new Error(job.message);
expect((await detail(job.deploymentId)).progress).toMatchObject({ phase: 'startup-queue', recipeStatus: 'queued', observedAt: null });
const { createScript, getLatestVersion } = await import('../../src/lib/server/fleet/scripts.js');
const { db } = await import('../../src/lib/server/db/index.js');
const { script } = createScript({ name: 'New promotion', targetRelPath: 'promoted.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('promoted while queued') });
db().prepare('UPDATE scripts SET current_version_id = ? WHERE id = ?').run(getLatestVersion(script.id)!.id, script.id);
await processCreations();
expect((await detail(job.deploymentId)).progress?.recipeStatus).toBe('attempted');
expect((await detail(job.deploymentId)).recipe?.files.some(file => file.path === 'promoted.gsc')).toBe(true);
vi.mocked(rconStatusResult).mockResolvedValueOnce(status('zombie_moon'));
vi.setSystemTime(Date.now() + 1000);
await processCreations();
expect((await detail(job.deploymentId)).progress).toMatchObject({ phase: 'verify-map', blocker: { code: 'map-mismatch' },
checks: [{ key: 'container', result: 'pass' }, { key: 'game', result: 'pass' }, { key: 'map', result: 'waiting', expected: 'zombie_theater', actual: 'zombie_moon' }] });
vi.mocked(komodoInspectHealthy).mockResolvedValueOnce({ ok: false, reason: 'container recovering' });
await processCreations();
expect((await detail(job.deploymentId)).progress?.checks).toEqual([expect.objectContaining({ key: 'container', result: 'waiting' })]);
vi.setSystemTime(Date.now() + 1000);
const receivedAt = await telemetry(0);
await processCreations();
const completed = await detail(job.deploymentId);
expect(completed.deployment.state).toBe('done');
expect(completed.progress?.checks).toContainEqual(expect.objectContaining({ key: 'game', source: 'telemetry', result: 'pass', receivedAt }));
expect(completed.progress?.checkpoints).toContainEqual(expect.objectContaining({ key: 'verify-map' }));
expect(komodoCreateDeployment).toHaveBeenCalledTimes(1);
expect(komodoDeploy).toHaveBeenCalledTimes(1);
});
it('keeps an uncertain failed request immutable while observing and releasing its exact recovery slot', async () => {
await register();
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoRestart).mockResolvedValueOnce({ ok: false, kind: 'unreachable', message: 'response lost' });
const job = requestLifecycle(server, 'restart');
if (!job.ok) throw new Error(job.error);
await processProvisioning();
const failed = await detail(job.deploymentId);
expect(failed.progress).toMatchObject({ command: { state: 'uncertain' }, recovery: { state: 'held', operationId: job.deploymentId } });
const failureEvents = failed.events.filter(event => event.kind === 'outcome');
vi.setSystemTime(Date.now() + 15_000);
await telemetry(0);
await processProvisioning();
const recovered = await detail(job.deploymentId);
expect(recovered.deployment).toMatchObject({ state: 'failed', finished_at: failed.deployment.finished_at });
expect(recovered.progress).toMatchObject({ command: { state: 'uncertain' }, recovery: { state: 'released', operationId: job.deploymentId } });
expect(recovered.events.filter(event => event.kind === 'outcome')).toEqual(failureEvents);
expect(komodoRestart).toHaveBeenCalledTimes(1);
});
it('records occupied and unknown evidence without treating either as zero or resetting eligibility timing', async () => {
await register();
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
const job = requestLifecycle(server, 'restart');
if (!job.ok) throw new Error(job.error);
vi.mocked(rconGetPlayerCount).mockResolvedValueOnce(null);
await processProvisioning();
const unknown = await detail(job.deploymentId);
expect(unknown.progress?.checks).toEqual([expect.objectContaining({ key: 'occupancy', actual: null, source: null, result: 'unknown' })]);
vi.setSystemTime(Date.now() + 1000);
await telemetry(3);
await processProvisioning();
expect((await detail(job.deploymentId)).progress).toMatchObject({ phaseStartedAt: unknown.progress?.phaseStartedAt,
blocker: { code: 'players-present' }, checks: [{ key: 'occupancy', actual: 3, source: 'telemetry' }] });
vi.setSystemTime(Date.now() + 1000);
await telemetry(2);
await processProvisioning();
expect((await detail(job.deploymentId)).progress?.phaseStartedAt).toBe(unknown.progress?.phaseStartedAt);
expect(komodoRestart).not.toHaveBeenCalled();
});
it('preserves failed cleanup evidence through same-ID resume without another destructive command', async () => {
await register();
const { db } = await import('../../src/lib/server/db/index.js');
const { destroyServer, processDestructions } = await import('../../src/lib/server/provisioning/instances.js');
const { resumeCleanup } = await import('../../src/lib/server/provisioning/history.js');
const { komodoInspectHealthy, komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, state: 'exited', reason: 'stopped' });
db().exec("CREATE TRIGGER fail_cleanup BEFORE DELETE ON servers BEGIN SELECT RAISE(ABORT, 'cleanup interrupted'); END");
const job = destroyServer(server);
if (!job.ok || !job.deploymentId) throw new Error('Could not queue removal');
await processDestructions();
const failed = await detail(job.deploymentId);
expect(failed.deployment.state).toBe('failed');
expect(failed.progress?.checkpoints).toContainEqual(expect.objectContaining({ key: 'delete-deployment' }));
expect(failed.progress?.checkpoints.some(point => point.key === 'cleanup')).toBe(false);
const failure = failed.events.find(event => event.kind === 'outcome')!;
db().exec('DROP TRIGGER fail_cleanup');
vi.setSystemTime(Date.now() + 1000);
expect(resumeCleanup(job.deploymentId)).toBe(true);
await processDestructions();
const completed = await detail(job.deploymentId);
expect(completed.deployment).toMatchObject({ id: job.deploymentId, state: 'done', server_exists: 0 });
expect(completed.events).toContainEqual(failure);
expect(completed.events.some(event => event.kind === 'resumed')).toBe(true);
expect(completed.progress?.checkpoints).toContainEqual(expect.objectContaining({ key: 'cleanup' }));
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(1);
expect(komodoDeleteDeployment).toHaveBeenCalledTimes(1);
});
it('links the actual removal dependency while later eligible creation bypasses it', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
const { queueCreateServer, processCreations } = await import('../../src/lib/server/provisioning/instances.js');
const input = { gameMode: 'zombie' as const, mapSlug: 'theater', plutoKey: 'saved-key' };
const first = queueCreateServer(input), second = queueCreateServer(input);
if (!first.ok || !second.ok) throw new Error('Could not queue creation');
const removal = beginDeployment('old-instance', 'destroy');
db().prepare('INSERT INTO server_creation_dependencies VALUES (?, ?)').run(first.deploymentId, removal);
await processCreations();
expect((await detail(first.deploymentId)).progress?.blocker).toMatchObject({ code: 'removal-dependency', href: `/provisioning/${removal}` });
expect((await detail(first.deploymentId)).deployment.state).toBe('queued');
expect((await detail(second.deploymentId)).deployment.state).toBe('awaiting-health');
});
it.each(['create', 'delete'] as const)('records uncertainty when the %s reply throws without inventing a successful checkpoint', async command => {
const komodo = await import('../../src/lib/server/integrations/komodo.js');
const { queueCreateServer, processCreations, destroyServer, processDestructions } = await import('../../src/lib/server/provisioning/instances.js');
let id: string;
if (command === 'create') {
vi.mocked(komodo.komodoCreateDeployment).mockRejectedValueOnce(new Error('reply lost'));
const job = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'saved-key' });
if (!job.ok) throw new Error(job.message);
id = job.deploymentId;
await processCreations();
expect(komodo.komodoCreateDeployment).toHaveBeenCalledTimes(1);
} else {
await register();
vi.mocked(komodo.komodoInspectHealthy).mockResolvedValue({ ok: false, state: 'exited', reason: 'stopped' });
vi.mocked(komodo.komodoDeleteDeployment).mockRejectedValueOnce(new Error('reply lost'));
const job = destroyServer(server);
if (!job.ok || !job.deploymentId) throw new Error('Could not queue removal');
id = job.deploymentId;
await processDestructions();
expect(komodo.komodoDeleteDeployment).toHaveBeenCalledTimes(1);
}
const failed = await detail(id);
expect(failed.deployment.state).toBe('failed');
expect(failed.progress?.command).toMatchObject({ state: 'uncertain', message: expect.stringContaining('reply lost') });
expect(failed.progress?.checkpoints.some(point => point.key === (command === 'create' ? 'create-deployment' : 'delete-deployment'))).toBe(false);
});
it.each(['destroy', 'rebuild'] as const)('retains the resumable %s checkpoint when a beta arrives after confirmed remote deletion', async action => {
await register();
const { db } = await import('../../src/lib/server/db/index.js');
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
const { destroyServer, processDestructions } = await import('../../src/lib/server/provisioning/instances.js');
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
const { resumeCleanup, resumeRebuild, REMOVING_SERVER } = await import('../../src/lib/server/provisioning/history.js');
const { komodoInspectHealthy, komodoDestroyDeployment, komodoDeleteDeployment } = await import('../../src/lib/server/integrations/komodo.js');
vi.mocked(komodoInspectHealthy).mockResolvedValue({ ok: false, state: 'exited', reason: 'stopped' });
vi.mocked(komodoDeleteDeployment).mockImplementationOnce(async () => {
const { script, version } = createScript({ name: 'Late beta', targetRelPath: 'beta.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('beta') });
const beta = db().prepare("INSERT INTO betas (script_id,script_version_id,trigger_kind,state) VALUES (?,?,'manual','running')").run(script.id, version.id).lastInsertRowid;
db().prepare("INSERT INTO beta_servers (beta_id,server,had_original,stage) VALUES (?,?,0,'ready')").run(beta, server);
return { ok: true };
});
const job = action === 'destroy' ? destroyServer(server) : requestLifecycle(server, 'rebuild');
if (!job.ok || !job.deploymentId) throw new Error('Could not queue removal');
await (action === 'destroy' ? processDestructions() : processProvisioning());
const failed = await detail(job.deploymentId);
expect(failed.deployment).toMatchObject({ state: 'failed', stage: REMOVING_SERVER });
expect(failed.progress?.checkpoints).toContainEqual(expect.objectContaining({ key: 'delete-deployment' }));
expect(action === 'destroy' ? failed.canResumeCleanup : failed.canResumeRebuild).toBe(true);
db().prepare("UPDATE betas SET state = 'rolled-back'").run();
expect(action === 'destroy' ? resumeCleanup(job.deploymentId) : resumeRebuild(job.deploymentId)).toBe(true);
await (action === 'destroy' ? processDestructions() : processProvisioning());
expect((await detail(job.deploymentId)).deployment.state).toBe(action === 'destroy' ? 'done' : 'queued');
expect(komodoDestroyDeployment).toHaveBeenCalledTimes(1);
expect(komodoDeleteDeployment).toHaveBeenCalledTimes(1);
});
it('ignores occupancy evidence that returns after the request was cancelled', async () => {
await register();
const { requestLifecycle, cancelProvisioning } = await import('../../src/lib/server/provisioning/requests.js');
const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js');
const { rconGetPlayerCount } = await import('../../src/lib/server/integrations/rcon.js');
const { komodoRestart } = await import('../../src/lib/server/integrations/komodo.js');
const job = requestLifecycle(server, 'restart');
if (!job.ok) throw new Error(job.error);
vi.mocked(rconGetPlayerCount).mockImplementationOnce(async () => {
expect(cancelProvisioning(job.deploymentId)).toBe(true);
return 4;
});
await processProvisioning();
const cancelled = await detail(job.deploymentId);
expect(cancelled.deployment.state).toBe('cancelled');
expect(cancelled.progress).toMatchObject({ checks: [], blocker: null });
expect(komodoRestart).not.toHaveBeenCalled();
});
it('does not attach replacement health to a deleted creation after a delayed probe', async () => {
const { db } = await import('../../src/lib/server/db/index.js');
const { queueCreateServer, processCreations } = await import('../../src/lib/server/provisioning/instances.js');
const { komodoInspectHealthy } = await import('../../src/lib/server/integrations/komodo.js');
const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js');
const job = queueCreateServer({ gameMode: 'zombie', mapSlug: 'theater', plutoKey: 'saved-key' });
if (!job.ok) throw new Error(job.message);
await processCreations();
vi.mocked(komodoInspectHealthy).mockImplementationOnce(async () => {
db().prepare('DELETE FROM servers WHERE name = ?').run(server);
await register();
return { ok: true, state: 'running' };
});
await processCreations();
const old = await detail(job.deploymentId);
expect(old.deployment.server_deleted_at).toBeTruthy();
expect(old.progress?.checks).toEqual([]);
expect(old.progress?.checkpoints.some(point => point.key.startsWith('verify-'))).toBe(false);
expect(rconStatusResult).not.toHaveBeenCalled();
});
});