import { putFileBlob } from '../../src/lib/server/db/file-blobs.js'; import { createHash } from 'node:crypto'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { join } from 'node:path'; import { newFixture, type Fixture } from '../helpers/fixture.js'; const remote = vi.hoisted(() => ({ state: 'running', players: 0, map: 'zombie_theater' })); vi.mock('../../src/lib/server/integrations/rcon.js', () => ({ rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: remote.players, map: remote.map, gametype: 'zom', roster: [] } })), rconGetPlayerCount: vi.fn(async () => remote.players), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null })) })); vi.mock('../../src/lib/server/integrations/plutonium.js', () => ({ checkRelease: vi.fn(async () => {}), freshRelease: () => 5354, probeVersion: vi.fn(async () => 5354) })); vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}), kickProvisioning: vi.fn(async () => {}) })); vi.mock('../../src/lib/server/rebalancing/admission.js', () => ({ rebalanceAdmission: vi.fn(() => null), requestRebalanceStopForOperation: vi.fn() })); let queuedKeys = 0; let fixture: Fixture; let hostId: string; let libraryId: string; const image = 'example.test/plutainer:test'; const inventory = () => ({ cpuCount: 16, cpuUsagePercent: 10, memoryTotal: 64 * 1024 ** 3, memoryAvailable: 60 * 1024 ** 3, protocolVersion: 1, capabilities: ['instance.unhealthy-recovery', 'instance.rebuild-files'], dockerRunning: true, directoriesWritable: true, storageAvailable: 100_000_000_000, preparedRuntimeImages: [image], libraries: libraryId ? [{ id: libraryId, state: 'ready' as const }] : [] }); beforeEach(async () => { vi.resetModules(); vi.clearAllMocks(); remote.state = 'running'; remote.players = 0; remote.map = 'zombie_theater'; libraryId = ''; queuedKeys = 0; fixture = await newFixture([]); vi.stubEnv('GAME_FILES_ROOT', join(fixture.root, 'libraries')); vi.stubEnv('PLUTAINER_IMAGE', image); vi.stubEnv('RCON_PASSWORD', 'test-password'); const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js'); hostId = createHost({ name: 'Native Debian', portMin: 28960, portMax: 28970 }).id; enrollHost(createEnrollment(hostId).token, inventory()); const backend = await import('../../src/lib/server/backends/native.js'); vi.spyOn(backend, 'nativeRequest').mockImplementation(async (_instance, kind) => { if (kind !== 'instance.inspect') throw new Error(`Unexpected operation: ${kind}`); return { healthy: remote.state === 'running', state: remote.state, exists: true } as never; }); }); afterEach(async () => { vi.useRealTimers(); await fixture.cleanup(); vi.unstubAllEnvs(); }); async function prepareLibrary(game: 't5' | 't6' = 't5') { const libs = await import('../../src/lib/server/game-files/index.js'); const version = libs.createLibraryImport({ hostId, game }); const bytes = Buffer.from('verified base game'); const file = { path: 'zone/base.ff', size: bytes.length, sha256: createHash('sha256').update(bytes).digest('hex') }; libs.setImportManifest(hostId, version.id, [file]); await libs.uploadImportFile(hostId, version.id, file.path, new Response(bytes).body); const ready = await libs.publishImport(hostId, version.id); libraryId = ready.id; libs.updateHostLibraryCaches(hostId, [ { libraryVersionId: ready.id, status: 'ready', manifestSha256: ready.manifestSha256 } ]); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, inventory()); return ready; } async function queue(game: 't5' | 't6' = 't5', placement: 'pinned' | 'automatic' = 'pinned') { await prepareLibrary(game); remote.map = game === 't5' ? 'zombie_theater' : 'zm_transit'; const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js'); const result = queueCreateServer({ game, gameMode: 'zombie', mapSlug: game === 't5' ? 'theater' : 'town', hostId: placement === 'pinned' ? hostId : undefined, libraryId, image, plutoKey: ++queuedKeys === 1 ? 'saved-key' : `saved-key-${queuedKeys}` }); if (!result.ok) throw new Error(result.message); return result; } async function operationFor(deploymentId: string) { const { db } = await import('../../src/lib/server/db/index.js'); const row = db() .prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?') .get(deploymentId) as { operation_id: string | null }; const { getHostOperation } = await import('../../src/lib/server/hosts/index.js'); return row?.operation_id ? getHostOperation(row.operation_id)! : null; } async function advance() { const { processNativeProvisioning } = await import('../../src/lib/server/provisioning/native.js'); await processNativeProvisioning(() => null); } async function acknowledge(deploymentId: string) { const operation = await operationFor(deploymentId); if (!operation) throw new Error('Missing durable native operation'); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { state: remote.state, healthy: remote.state === 'running' } }); await advance(); return operation; } async function detail(id: string) { const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); return getDeployment(id)!.deployment; } async function observations(server: string) { const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { serverObservation, observationReader } = await import('../../src/lib/server/fleet/status.js'); const { MonitoringSnapshot } = await import('../../src/lib/server/fleet/monitoring-snapshot.js'); const row = getServer(server)!; return [ serverObservation(row), observationReader()(row), new MonitoringSnapshot().observe(row) ]; } async function moveTarget() { const hosts = await import('../../src/lib/server/hosts/index.js'); const libraries = await import('../../src/lib/server/game-files/index.js'); const target = hosts.createHost({ name: 'Rebalance destination', portMin: 29000, portMax: 29005 }); hosts.enrollHost(hosts.createEnrollment(target.id).token, inventory()); libraries.updateHostLibraryCaches(target.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: libraries.getLibraryVersion(libraryId)!.manifestSha256 } ]); return target; } describe('rebalance instance migrations', () => { it('preserves deployed configuration, patched file versions, identity and credentials on an active source', async () => { const { config } = await import('../../src/lib/server/config.js'); Object.assign(config.rcon, { password: '' }); const { createScript, uploadNewVersion } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const baseline = createScript({ name: 'Deployed baseline', kind: 'baseline', scopeType: 'game', scopeValue: 't5', content: Buffer.from('set sv_voice "1"\n') }); const script = createScript({ releaseVersion: '1.0.0', name: 'Deployed script', kind: 'script', targetRelPath: 'preserved.gsc', scopeType: 'game', scopeValue: 't5', content: Buffer.from('original deployed script') }); for (const artifact of [baseline, script]) db() .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(artifact.version.id, artifact.script.id); const job = await queue(); await advance(); const launch = (await operationFor(job.deploymentId))!.payload as { rconPassword: string; files: { path: string; sha256: string }[]; }; await acknowledge(job.deploymentId); const patched = uploadNewVersion( script.script.id, Buffer.from('confirmed patch after initial launch'), undefined, '1.0.1' ); const { recordScript } = await import('../../src/lib/server/fleet/inventory.js'); recordScript( job.name, 'preserved.gsc', patched.id, patched.sha256, patched.size, 'campaign' ); const newest = uploadNewVersion( script.script.id, Buffer.from('new promotion must not be installed'), undefined, '1.0.2' ); db() .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(newest.id, script.script.id); const newBaseline = uploadNewVersion(baseline.script.id, Buffer.from('set sv_voice "0"\n')); db() .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(newBaseline.id, baseline.script.id); Object.assign(config.rcon, { password: 'new-controller-password' }); const target = await moveTarget(); const { captureMoveRecipe, requestInstanceMove, hostMigration } = await import('../../src/lib/server/provisioning/host-drain.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const original = nativeInstance(job.name)!; const recipe = captureMoveRecipe(job.name); expect(recipe.files.every((file) => file.content === undefined)).toBe(true); const id = requestInstanceMove(job.name, target.id, { triggeredBy: 'rebalance:test', recipe }); await advance(); const removal = hostMigration(id)!.remove_operation_id!; expect(removal).toBeTruthy(); const { cancelProvisioningJob, canCancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); expect(canCancelProvisioningJob(id)).toBe(false); expect(cancelProvisioningJob(id)).toBe(false); const { getDeployment, listDeployments } = await import('../../src/lib/server/provisioning/history.js'); expect(getDeployment(id)?.canCancel).toBe(false); expect(listDeployments().find((item) => item.id === id)?.canCancel).toBe(0); const { POST } = await import('../../src/routes/api/deployments/[id]/cancel/+server.js'); const response = POST({ params: { id } }); expect(response.status).toBe(409); expect(await response.json()).toMatchObject({ error: expect.stringContaining('must finish recreating') }); const { reportHostOperation, getHost } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, removal, { state: 'completed' }); await advance(); await advance(); const creation = (await operationFor(id))!; expect(creation).toMatchObject({ hostId: target.id, kind: 'instance.create', generation: 2, payload: { name: job.name, libraryId, image, rconPassword: launch.rconPassword, env: { PLUTO_SERVER_KEY: 'saved-key' } } }); const payload = creation.payload as { files: { path: string; sha256: string }[] }; expect(payload.files).toContainEqual( expect.objectContaining({ path: expect.stringContaining('preserved.gsc'), sha256: patched.sha256 }) ); expect(payload.files.find((file) => file.path === 'configs/dedicated_sp.cfg')?.sha256).toBe( launch.files.find((file) => file.path === 'configs/dedicated_sp.cfg')?.sha256 ); reportHostOperation(target.id, creation.id, { state: 'completed', result: { state: 'running', healthy: true } }); await advance(); expect((await detail(id)).state).toBe('done'); expect(nativeInstance(job.name)).toMatchObject({ id: original.id, host_id: target.id, generation: 2 }); expect(getHost(hostId)?.mode).toBe('active'); expect(JSON.stringify(getDeployment(id)?.recipe)).not.toContain(launch.rconPassword); }); it('rechecks occupancy, target capacity and run admission before removing the source', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const target = await moveTarget(); const { requestInstanceMove, hostMigration } = await import('../../src/lib/server/provisioning/host-drain.js'); expect(() => requestInstanceMove(job.name, hostId, { triggeredBy: 'rebalance:test' }) ).toThrow('different destination'); const id = requestInstanceMove(job.name, target.id, { triggeredBy: 'rebalance:test' }); remote.players = 2; await advance(); expect(hostMigration(id)?.remove_operation_id).toBeNull(); remote.players = 0; const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(target.id, { ...inventory(), memoryAvailable: 1 }); await advance(); expect(hostMigration(id)?.remove_operation_id).toBeNull(); heartbeat(target.id, inventory()); const { rebalanceAdmission } = await import('../../src/lib/server/rebalancing/admission.js'); vi.mocked(rebalanceAdmission).mockReturnValueOnce('Run stopped during occupancy check.'); await advance(); expect(hostMigration(id)?.remove_operation_id).toBeNull(); await advance(); expect(hostMigration(id)?.remove_operation_id).toEqual(expect.any(String)); }); it('withdraws only uncommitted moves and preserves their server keys', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const target = await moveTarget(); const { requestInstanceMove } = await import('../../src/lib/server/provisioning/host-drain.js'); const id = requestInstanceMove(job.name, target.id, { triggeredBy: 'rebalance:test' }); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); expect(cancelProvisioningJob(id)).toBe(true); const { requestRebalanceStopForOperation } = await import('../../src/lib/server/rebalancing/admission.js'); expect(requestRebalanceStopForOperation).toHaveBeenCalledWith(id); await advance(); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); expect(getServerKey(job.name)).toBe('saved-key'); const { db } = await import('../../src/lib/server/db/index.js'); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'").all() ).toEqual([]); }); it('refuses to move when recorded deployed bytes cannot be restored', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { captureMoveRecipe } = await import('../../src/lib/server/provisioning/host-drain.js'); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE server_scripts SET sha256=? WHERE server=?') .run('0'.repeat(64), job.name); expect(() => captureMoveRecipe(job.name)).toThrow('deployed bytes are unavailable'); }); }); describe('runtime configuration at dispatch', () => { it.each([ ['t5', 'shared'], ['t5', 'generated'], ['t6', 'shared'], ['t6', 'generated'] ] as const)( 'dispatches %s with a promoted baseline and %s RCON password', async (game, source) => { const { config, configFileForGame } = await import('../../src/lib/server/config.js'); if (source === 'generated') Object.assign(config.rcon, { password: '' }); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { script, version } = createScript({ name: 'Launch baseline', kind: 'baseline', scopeType: 'game', scopeValue: game, content: Buffer.from('set rcon_password "password"\nset sv_voice "1"\n') }); db() .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(version.id, script.id); const job = await queue(game); await advance(); const operation = await operationFor(job.deploymentId); expect(operation).toMatchObject({ kind: 'instance.create', state: 'pending' }); const payload = operation!.payload as { rconPassword: string; files: { path: string; contentBase64: string; sha256: string }[]; }; expect(payload.rconPassword).toEqual( source === 'shared' ? 'test-password' : expect.stringMatching(/^[a-f0-9]{64}$/) ); const configFile = configFileForGame(game); const file = payload.files.find((file) => file.path === `configs/${configFile}`)!; const content = Buffer.from(file.contentBase64, 'base64'); expect(content.toString()).toContain(`set rcon_password "${payload.rconPassword}"`); expect(content.toString()).not.toContain('set rcon_password "password"'); const hash = createHash('sha256').update(content).digest('hex'); expect(file.sha256).toBe(hash); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); expect(getServerScript(job.name, configFile)).toMatchObject({ sha256: hash, size: content.length, source: 'native-create' }); const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); expect(recipeForDeployment(job.deploymentId)!.recipe.files).toContainEqual( expect.objectContaining({ path: configFile, sha256: hash, size: content.length, content: content.toString('base64') }) ); } ); }); describe('provisioning health handoff', () => { it.each(['create', 'start', 'restart', 'rebuild'] as const)( '%s publishes verified game health before completion becomes visible', async (action) => { const job = await queue(); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); let id = job.deploymentId; if (action !== 'create') { await acknowledge(id); db() .prepare('UPDATE servers SET container_state=? WHERE name=?') .run(action === 'start' ? 'stopped' : 'running', job.name); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const request = requestLifecycle(job.name, action); if (!request.ok) throw new Error(request.error); id = request.deploymentId; await advance(); } const old = new Date(Date.now() - 120_000).toISOString(); db() .prepare( `UPDATE servers SET last_seen_at=?,last_checked_at=?,last_players=0,last_roster='[]', last_error='timeout:old process',rcon_failures=2,active_since=NULL,inactive_since=? WHERE name=?` ) .run(old, old, old, job.name); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 1000); if (action === 'rebuild') { // A new generation now establishes its own durable evidence boundary. // Observe game health strictly after that transition. const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); vi.mocked(nativeRequest).mockImplementationOnce(async () => { vi.setSystemTime(Date.now() + 1); return { healthy: true, state: 'running', exists: true } as never; }); } const observedAt = new Date(Date.now() + (action === 'rebuild' ? 1 : 0)).toISOString(); const roster = [ { num: 0, name: 'Player', guid: '12345678901234567', score: 500, ping: 20 } ]; const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconStatusResult).mockResolvedValueOnce({ ok: true, endpoint: { host: 'agent', port: 28960 }, status: { players: 1, map: remote.map, gametype: 'zom', roster } }); const { subscribe } = await import('../../src/lib/server/pipeline/bus.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { serverObservation } = await import('../../src/lib/server/fleet/status.js'); const visible: string[] = []; const unsubscribe = subscribe((event) => { if ( event.type === 'fleet-invalidated' && getDeployment(id)?.deployment.state === 'done' ) { visible.push(serverObservation(getServer(job.name)!).health); } }); try { await acknowledge(id); } finally { unsubscribe(); } expect((await detail(id)).state).toBe('done'); expect(visible).toEqual(['online']); expect(getServer(job.name)).toMatchObject({ last_checked_at: observedAt, last_seen_at: observedAt, last_players: 1, last_roster: JSON.stringify(roster), last_reply_snippet: null, last_error: null, rcon_failures: 0, active_since: observedAt, inactive_since: null }); for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', last_players: 1, roster }); } ); it('completes directly from persisted telemetry without inventing an RCON observation', async () => { const job = await queue(); await advance(); const operation = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { state: 'running', healthy: true } }); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 1000); const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js'); const { gameEvent } = await import('../helpers/telemetry.js'); const receivedAt = new Date().toISOString(); saveTelemetry({ ...gameEvent('heartbeat', 0, 0, job.name), receivedAt }); await advance(); expect((await detail(job.deploymentId)).state).toBe('done'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); expect(getServer(job.name)).toMatchObject({ last_seen_at: null, inactive_since: receivedAt }); for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', status_source: 'telemetry', last_seen_at: receivedAt }); }); it('rechecks expired verification evidence instead of renewing its timestamps', async () => { const job = await queue(); await advance(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 1000); const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js'); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); vi.mocked(probeVersion).mockImplementationOnce(async () => { vi.setSystemTime(Date.now() + 91_000); heartbeat(hostId, inventory()); return 5354; }); await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('awaiting-health'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); expect(getServer(job.name)?.last_seen_at).toBeNull(); for (const status of await observations(job.name)) expect(status.health).toBe('provisioning'); await advance(); expect((await detail(job.deploymentId)).state).toBe('done'); expect(getServer(job.name)?.last_seen_at).toBe(new Date().toISOString()); }); it.each(['instance', 'lifecycle'] as const)( 'discards verification when the %s changes during later checks', async (change) => { const job = await queue(); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js'); vi.mocked(probeVersion).mockImplementationOnce(async () => { if (change === 'instance') db() .prepare( 'UPDATE native_instances SET generation=generation+1 WHERE server=?' ) .run(job.name); else db() .prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?') .run(new Date(Date.now() + 1000).toISOString(), job.deploymentId); return 5354; }); await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('awaiting-health'); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); expect(getServer(job.name)).toMatchObject({ last_seen_at: null, container_checked_at: null }); } ); it.each(['online', 'offline'] as const)( 'preserves newer %s monitoring evidence received during version verification', async (health) => { const job = await queue(); await advance(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 1000); const { db } = await import('../../src/lib/server/db/index.js'); const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js'); let newer: string; vi.mocked(probeVersion).mockImplementationOnce(async () => { vi.setSystemTime(Date.now() + 1000); newer = new Date().toISOString(); db() .prepare( `UPDATE servers SET container_state=?,container_checked_at=?,last_seen_at=?,last_checked_at=?, last_players=2,last_roster='[]',last_error=NULL WHERE name=?` ) .run( health === 'online' ? 'running' : 'stopped', newer, newer, newer, job.name ); return 5354; }); await acknowledge(job.deploymentId); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); expect(getServer(job.name)).toMatchObject({ last_seen_at: newer!, container_checked_at: newer!, last_players: 2 }); expect((await detail(job.deploymentId)).state).toBe( health === 'online' ? 'done' : 'awaiting-health' ); if (health === 'online') for (const status of await observations(job.name)) expect(status).toMatchObject({ health: 'online', last_players: 2 }); } ); it('keeps a slow restart Starting until verification succeeds and surfaces terminal failures', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const request = requestLifecycle(job.name, 'restart'); if (!request.ok) throw new Error(request.error); await advance(); vi.useFakeTimers({ toFake: ['Date'] }); vi.setSystemTime(Date.now() + 20_000); for (const status of await observations(job.name)) expect(status.health).toBe('starting'); const { db } = await import('../../src/lib/server/db/index.js'); for (const state of ['failed', 'cancelled', 'interrupted']) { db() .prepare('UPDATE server_deployments SET state=? WHERE id=?') .run(state, request.deploymentId); for (const status of await observations(job.name)) expect(status.health).toBe('offline'); } db() .prepare("UPDATE server_deployments SET state='running' WHERE id=?") .run(request.deploymentId); await acknowledge(request.deploymentId); expect((await detail(request.deploymentId)).state).toBe('done'); for (const status of await observations(job.name)) expect(status.health).toBe('online'); }); }); describe('native instance provisioning', () => { it('recognizes an unplaced native intent and resumes it once after a controller restart', async () => { const job = await queue(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { nativeDeployment, nativeInstance } = await import('../../src/lib/server/backends/native.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); expect(job.port).toBeNull(); expect(nativeDeployment(job.deploymentId)).toBe(true); expect(nativeInstance(job.name)).toBeNull(); expect(getDeployment(job.deploymentId)).toMatchObject({ backend: 'native', canCancelCreation: true }); expect(db().prepare('SELECT deployment_id FROM native_creation_intents').all()).toEqual([ { deployment_id: job.deploymentId } ]); expect(db().prepare('SELECT instance_id FROM ec_host_ports').all()).toEqual([]); closeDb(); vi.resetModules(); const backend = await import('../../src/lib/server/backends/native.js'); expect(backend.nativeDeployment(job.deploymentId)).toBe(true); await advance(); const original = await operationFor(job.deploymentId); expect(original).toMatchObject({ kind: 'instance.create', payload: { name: job.name, port: 28960 } }); await advance(); expect((await operationFor(job.deploymentId))?.id).toBe(original!.id); const restartedDb = (await import('../../src/lib/server/db/index.js')).db(); expect( restartedDb .prepare("SELECT id FROM ec_host_operations WHERE kind='instance.create'") .all() ).toHaveLength(1); expect( restartedDb.prepare('SELECT protocol FROM ec_host_ports ORDER BY protocol').all() ).toEqual([{ protocol: 'tcp' }, { protocol: 'udp' }]); }); it('still rejects missing keys and stale lobby identities before creating an intent', async () => { await prepareLibrary(); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const input = { game: 't5' as const, gameMode: 'zombie' as const, mapSlug: 'theater', hostId, libraryId, image }; expect(queueNativeCreation(input)).toMatchObject({ ok: false, message: expect.stringContaining('server key') }); expect( queueNativeCreation({ ...input, plutoKey: 'saved-key', expectedName: 't5_zombie_theater_2' }) ).toMatchObject({ ok: false }); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT deployment_id FROM native_creation_intents').all()).toEqual([]); expect(db().prepare('SELECT id FROM server_deployments').all()).toEqual([]); expect(db().prepare('SELECT instance_id FROM ec_host_ports').all()).toEqual([]); }); it('accepts creation cleanup through the provisioning API and rejects an already completed creation', async () => { const job = await queue(); const { POST } = await import('../../src/routes/api/provisioning/[id]/cancel-creation/+server.js'); const response = POST({ params: { id: job.deploymentId } }); expect(response.status).toBe(200); expect(await response.json()).toMatchObject({ ok: true, cleanupId: expect.any(String) }); const next = await queue(); await advance(); await acknowledge(next.deploymentId); expect(POST({ params: { id: next.deploymentId } }).status).toBe(409); expect(POST({ params: { id: job.deploymentId } }).status).toBe(200); }); it('cancels an unsubmitted creation locally and returns the same cleanup on repeated requests', async () => { const job = await queue(); const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { db } = await import('../../src/lib/server/db/index.js'); expect(getDeployment(job.deploymentId)!.canCancelCreation).toBe(true); const cleanup = cancelCreation(job.deploymentId)!; expect(cancelCreation(job.deploymentId)).toBe(cleanup); expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect((await detail(cleanup)).state).toBe('done'); expect(getDeployment(job.deploymentId)!.cancellation?.state).toBe('done'); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); await advance(); expect(await operationFor(job.deploymentId)).toBeNull(); expect(await operationFor(cleanup)).toBeNull(); }); it('cancels a submitted creation without occupancy checks, suppresses replay, and releases resources only after cleanup', async () => { const job = await queue(); await advance(); remote.players = 4; const original = (await operationFor(job.deploymentId))!; const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const { heartbeat, pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); heartbeat(hostId, { ...inventory(), capabilities: ['instance.cancel-create'] }); // A previously delivered command might still be executing when cancellation arrives. expect(pendingHostOperations(hostId).map((op) => op.id)).toContain(original.id); const cleanup = cancelCreation(job.deploymentId)!; const removal = (await operationFor(cleanup))!; expect(removal.kind).toBe('instance.cancel-create'); expect(removal.payload).toEqual({ createOperationId: original.id }); expect(cancelCreation(job.deploymentId)).toBe(cleanup); expect(pendingHostOperations(hostId).map((op) => op.id)).toContain(removal.id); expect(pendingHostOperations(hostId).map((op) => op.id)).not.toContain(original.id); await advance(); expect(nativeRequest).not.toHaveBeenCalled(); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2); reportHostOperation(hostId, original.id, { state: 'completed', result: { healthy: true } }); await advance(); expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect(nativeRequest).not.toHaveBeenCalled(); reportHostOperation(hostId, removal.id, { state: 'completed', result: { exists: false } }); await advance(); expect((await detail(cleanup)).state).toBe('done'); expect((await detail(job.deploymentId)).server_deleted_at).toBeTruthy(); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); expect(db().prepare('SELECT * FROM servers WHERE name=?').get(job.name)).toBeUndefined(); expect( db() .prepare('SELECT retired_at FROM native_instances WHERE id=?') .get(original.instanceId) ).toMatchObject({ retired_at: expect.any(String) }); }); it('holds cancellation for an old or offline host without delivering an unsupported command or losing reservations', async () => { const job = await queue(); await advance(); const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const { heartbeat, pendingHostOperations } = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const cleanup = cancelCreation(job.deploymentId)!; expect( pendingHostOperations(hostId).filter((op) => op.kind.startsWith('instance.')) ).toEqual([]); await advance(); expect((await detail(cleanup)).stage).toContain('Update the host agent'); expect((await detail(cleanup)).stage).toContain('Automatic updates are not enabled'); heartbeat(hostId, { ...inventory(), capabilities: ['agent.update'], agentUpdate: { enabled: true, state: 'deferred', currentVersion: '1.2.2', targetVersion: '1.3.0' } }); await advance(); expect((await detail(cleanup)).stage).toContain('New command delivery is paused'); expect((await detail(cleanup)).stage).toContain('automatic updater can run'); db() .prepare('UPDATE ec_hosts SET last_heartbeat=? WHERE id=?') .run('2000-01-01T00:00:00.000Z', hostId); await advance(); expect((await detail(cleanup)).stage).toContain('unavailable'); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toHaveLength(2); heartbeat(hostId, { ...inventory(), capabilities: ['instance.cancel-create'] }); expect( pendingHostOperations(hostId).filter((op) => op.kind === 'instance.cancel-create') ).toHaveLength(1); await acknowledge(cleanup); expect((await detail(cleanup)).state).toBe('done'); }); it('replaces an occupancy-blocked removal and ignores an outstanding creation health reply', async () => { const job = await queue(); await advance(); const operation = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { healthy: true } }); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const pending = advance(); try { const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const waitingRemoval = destroyServer(job.name).deploymentId!; const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const cleanup = cancelCreation(job.deploymentId)!; expect(cleanup).toBeTruthy(); expect((await detail(waitingRemoval)).state).toBe('cancelled'); release({ healthy: true, state: 'running' } as never); await pending; expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect((await detail(cleanup)).state).toBe('running'); } finally { release({ healthy: true, state: 'running' } as never); await pending; } }); it('does not offer creation cancellation after success or against a replacement instance', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { canCancelCreation, cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); expect(canCancelCreation(job.deploymentId)).toBe(false); expect(cancelCreation(job.deploymentId)).toBeNull(); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare("UPDATE server_deployments SET state='awaiting-health' WHERE id=?") .run(job.deploymentId); db() .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(job.name); expect(cancelCreation(job.deploymentId)).toBeNull(); }); it('retains every native launch checkpoint and readiness check through completion and UI validation', async () => { const job = await queue(); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { isProvisioningDetail, provisioningGroups } = await import('../../src/lib/ui/provisioning-presentation.js'); const queued = getDeployment(job.deploymentId)!; expect(queued.progress?.phase).toBe('eligibility'); expect(queued.progress?.blocker).toBeNull(); expect(queued.backend).toBe('native'); await advance(); expect(getDeployment(job.deploymentId)!.progress?.recipeStatus).toBe('attempted'); await acknowledge(job.deploymentId); const result = getDeployment(job.deploymentId)!; expect(result.deployment.state).toBe('done'); expect(isProvisioningDetail(result, job.deploymentId)).toBe(true); expect(result.progress?.checks.map((check) => check.key)).toEqual([ 'container', 'game', 'map', 'plutonium-release', 'plutonium-version' ]); expect(result.progress?.checks.every((check) => check.result === 'pass')).toBe(true); expect(result.progress?.checks[0].source).toBe('agent'); const steps = provisioningGroups(result).flatMap((group) => group.steps); expect(steps.every((step) => step.state === 'done')).toBe(true); expect(result.agentOperation?.state).toBe('completed'); }); it('records agent receipts immediately and only once, even before the provisioning worker runs', async () => { const job = await queue(); await advance(); const operation = (await operationFor(job.deploymentId))!; const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); for (const state of ['accepted', 'accepted', 'running', 'completed', 'completed']) { reportHostOperation(hostId, operation.id, { state, result: { state: 'running', healthy: true } }); } const result = getDeployment(job.deploymentId)!; expect(result.deployment.state).toBe('running'); expect(result.agentOperation?.state).toBe('completed'); expect(result.events.filter((event) => event.kind === 'agent')).toHaveLength(3); expect(result.progress?.checkpoints.map((point) => point.key)).toEqual( expect.arrayContaining(['agent-accepted', 'agent-running', 'agent-completed']) ); expect(result.progress?.command?.state).toBe('accepted'); }); it('records an outstanding container check before awaiting the host and admits other jobs meanwhile', async () => { const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 2 }); const first = await queue(); await advance(); const operation = (await operationFor(first.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { healthy: true } }); const second = await queue(); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const pending = advance(); try { const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); expect(getDeployment(first.deploymentId)!.progress?.phase).toBe('verify-container'); await vi.waitFor( async () => { expect((await operationFor(second.deploymentId))?.kind).toBe('instance.create'); }, { timeout: 5_000 } ); } finally { release({ healthy: true, state: 'running' } as never); await pending; } }); it('admits a later request on the next scheduler pass while an earlier native probe is pending', async () => { const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 2 }); const first = await queue(); await advance(); const operation = (await operationFor(first.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { healthy: true } }); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js'); await processProvisioning({ wait: false }); try { const second = await queue(); await processProvisioning({ wait: false }); await vi.waitFor( async () => { expect((await operationFor(second.deploymentId))?.kind).toBe('instance.create'); }, { timeout: 5_000 } ); expect(vi.mocked(nativeRequest)).toHaveBeenCalledTimes(1); } finally { release({ healthy: true, state: 'running' } as never); await processProvisioning(); } }); it('ignores a late health result after removal retires the operation', async () => { const job = await queue(); await advance(); const operation = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { healthy: true } }); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const pending = advance(); try { await vi.waitFor(() => expect(release).toBeTypeOf('function')); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( "UPDATE server_deployments SET state='cancelled',server_deleted_at=? WHERE id=?" ) .run(new Date().toISOString(), job.deploymentId); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const before = getDeployment(job.deploymentId)!; release({ healthy: true, state: 'running' } as never); await pending; const after = getDeployment(job.deploymentId)!; expect(after.deployment.state).toBe('cancelled'); expect(after.progress).toEqual(before.progress); expect(after.activity).toEqual(before.activity); } finally { release({ healthy: true, state: 'running' } as never); await pending; } }); it('records map mismatch evidence while retaining passed container and game checks', async () => { const job = await queue(); await advance(); remote.map = 'zombie_moon'; await acknowledge(job.deploymentId); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const result = getDeployment(job.deploymentId)!; expect(result.deployment.state).toBe('awaiting-health'); expect(result.progress?.phase).toBe('verify-map'); expect(result.progress?.checks).toEqual( expect.arrayContaining([ expect.objectContaining({ key: 'container', result: 'pass' }), expect.objectContaining({ key: 'game', result: 'pass' }), expect.objectContaining({ key: 'map', result: 'waiting', actual: 'zombie_moon', expected: 'zombie_theater' }) ]) ); }); it('selects the latest game files automatically and rejects stale explicit selections', async () => { const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js'); expect(() => nativeSelection({ hostId, image }, 't5')).toThrow('Upload T5 game files'); const old = await prepareLibrary('t5'); expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(old.id); const latest = await prepareLibrary('t5'); expect(nativeSelection({ hostId, image }, 't5').libraryId).toBe(latest.id); expect(() => nativeSelection({ hostId, image, libraryId: old.id }, 't5')).toThrow( 'Refresh the launch preview' ); }); it('rejects a launch preview made before newer game files were published without reserving a server', async () => { await prepareLibrary('t5'); const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const preview = previewNativeCreation({ hostId, image, game: 't5', mapSlug: 'theater' }); await prepareLibrary('t5'); const result = queueNativeCreation({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', previewId: preview.id, plutoKey: 'new-key' }); expect(result).toMatchObject({ ok: false, message: expect.stringContaining('Refresh the launch preview') }); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT COUNT(*) AS count FROM native_instances').get()).toEqual({ count: 0 }); expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 0 }); }); it.each(['pinned', 'automatic'] as const)( 'keeps an unplaced %s launch pinned to its accepted files during cleanup', async (placement) => { const job = await queue('t5', placement); const acceptedId = libraryId; const latest = await prepareLibrary('t5'); expect(latest.id).not.toBe(acceptedId); const { db } = await import('../../src/lib/server/db/index.js'); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { reconcileHostLibraryCleanup, cleanupControllerLibraries, getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); const accepted = getLibraryVersion(acceptedId)!; heartbeat(hostId, { ...inventory(), capabilities: ['library.remove'] }); expect(db().prepare('SELECT id FROM native_instances').all()).toEqual([]); reconcileHostLibraryCleanup(hostId); expect( db() .prepare( "SELECT id FROM ec_host_operations WHERE kind='library.remove' AND json_extract(payload,'$.libraryVersionId')=?" ) .all(acceptedId) ).toEqual([]); // The unplaced intent also protects the controller's copy when every // host reports it absent; its accepted version remains downloadable. updateHostLibraryCaches(hostId, [{ libraryVersionId: acceptedId, status: 'missing' }]); await cleanupControllerLibraries(); expect( db() .prepare('SELECT version_id FROM game_file_cleanup WHERE version_id=?') .get(acceptedId) ).toBeUndefined(); const { stat } = await import('node:fs/promises'); expect( ( await stat(join(fixture.root, 'libraries', 't5', acceptedId, 'zone/base.ff')) ).isFile() ).toBe(true); updateHostLibraryCaches(hostId, [ { libraryVersionId: acceptedId, status: 'ready', manifestSha256: accepted.manifestSha256 } ]); await advance(); expect((await operationFor(job.deploymentId))?.payload.libraryId).toBe(acceptedId); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.library_id).toBe(acceptedId); } ); it.each(['t5', 't6'] as const)( 'queues and verifies a %s deployment through its host agent', async (game) => { const job = await queue(game); await advance(); const operation = await operationFor(job.deploymentId); expect(job.port).toBeNull(); expect(operation).toMatchObject({ kind: 'instance.create', hostId, generation: 1, state: 'pending', payload: { game, libraryId, image, port: 28960 } }); expect(operation?.payload.files).toEqual( expect.arrayContaining([ expect.objectContaining({ path: 'configs/electric_cherry_map.cfg' }) ]) ); await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('done'); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare( 'SELECT protocol FROM ec_host_ports WHERE instance_id=? ORDER BY protocol' ) .all(operation!.instanceId) ).toEqual([{ protocol: 'tcp' }, { protocol: 'udp' }]); } ); it('reconciles the same operation after controller restart and an ambiguous remote result', async () => { const job = await queue(); await advance(); const original = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, original.id, { state: 'unknown', error: 'Reply lost after execution' }); const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb(); const { interruptDeployments } = await import('../../src/lib/server/provisioning/history.js'); interruptDeployments(); await advance(); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.create'" ) .get() ).toEqual({ count: 1 }); expect((await detail(job.deploymentId)).state).toBe('running'); await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('done'); }); it('holds disconnected instances as unknown without replacement and resumes the original operation', async () => { const job = await queue(); await advance(); const original = (await operationFor(job.deploymentId))!; const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE ec_hosts SET last_heartbeat=? WHERE id=?') .run('2000-01-01T00:00:00Z', hostId); await advance(); expect( db().prepare('SELECT container_state FROM servers WHERE name=?').get(job.name) ).toEqual({ container_state: 'unknown' }); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, inventory()); await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('done'); }); it('rebuilds only an empty instance and advances its generation after the acknowledged command', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); remote.players = 2; await advance(); expect(await operationFor(rebuild.deploymentId)).toBeNull(); remote.players = 0; await advance(); const operation = (await operationFor(rebuild.deploymentId))!; expect(operation).toMatchObject({ kind: 'instance.rebuild', generation: 1, payload: { nextGeneration: 2, libraryId } }); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(1); await acknowledge(rebuild.deploymentId); expect(nativeInstance(job.name)?.generation).toBe(2); expect((await detail(rebuild.deploymentId)).state).toBe('done'); await advance(); expect(nativeInstance(job.name)?.generation).toBe(2); }); it('carries confirmed admin override through serialized rebuild preparation', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), capabilities: ['instance.safety-override'] }); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); remote.players = 2; await advance(); expect(await operationFor(rebuild.deploymentId)).toBeNull(); const { enableSafetyOverride } = await import('../../src/lib/server/provisioning/safety-override.js'); expect( enableSafetyOverride(rebuild.deploymentId, { id: 'admin', username: 'admin' }) ).toEqual({ ok: true }); await advance(); expect(await operationFor(rebuild.deploymentId)).toMatchObject({ kind: 'instance.rebuild', generation: 1, payload: { nextGeneration: 2, libraryId, safetyOverride: true } }); await acknowledge(rebuild.deploymentId); expect((await detail(rebuild.deploymentId)).state).toBe('done'); }); it('holds a rebuild when players join while its launch files are being prepared', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); const preparation = await import('../../src/lib/server/provisioning/launch-preparation.js'); const prepare = preparation.prepareNativeLaunch; let started!: () => void; let release!: () => void; const ready = new Promise((resolve) => { started = resolve; }); const gate = new Promise((resolve) => { release = resolve; }); const spy = vi .spyOn(preparation, 'prepareNativeLaunch') .mockImplementationOnce(async (input) => { const result = await prepare(input); started(); await gate; return result; }); const pending = advance(); try { await ready; remote.players = 2; release(); await pending; expect(await operationFor(rebuild.deploymentId)).toBeNull(); remote.players = 0; await advance(); expect((await operationFor(rebuild.deploymentId))?.kind).toBe('instance.rebuild'); } finally { release(); await pending; spy.mockRestore(); } }); it('finishes native rebuild verification without waiting for its own recovery slot to be released', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js'); const actual = await vi.importActual< typeof import('../../src/lib/server/integrations/plutonium.js') >('../../src/lib/server/integrations/plutonium.js'); const backend = await import('../../src/lib/server/backends/native.js'); vi.spyOn(backend, 'nativeHealth').mockResolvedValue({ ok: true, state: 'running' }); vi.mocked(probeVersion).mockImplementationOnce(actual.probeVersion); // Version evidence must follow the submitted command, even on a fast test host. db() .prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?') .run(new Date(Date.now() - 1000).toISOString(), rebuild.deploymentId); db() .prepare('UPDATE restart_rollout SET requested_at=? WHERE operation_id=?') .run(new Date(Date.now() - 1000).toISOString(), rebuild.deploymentId); await acknowledge(rebuild.deploymentId); expect((await detail(job.deploymentId)).state).toBe('done'); expect((await detail(rebuild.deploymentId)).state).toBe('done'); expect( db() .prepare('SELECT healthy_at FROM restart_rollout WHERE operation_id=?') .get(rebuild.deploymentId) ).toEqual({ healthy_at: expect.any(String) }); expect( actual.runningVersions().find((version) => version.server === job.name)?.revision ).toBe(5354); const { getServer } = await import('../../src/lib/server/fleet/servers.js'); const { serverObservation } = await import('../../src/lib/server/fleet/status.js'); const { fleetOperations } = await import('../../src/lib/server/fleet/overview.js'); expect(serverObservation(getServer(job.name)!).health).not.toBe('provisioning'); expect( fleetOperations().filter( (operation) => operation.server === job.name && operation.state !== 'done' ) ).toEqual([]); }); it('resumes a rebuild waiting on version evidence after controller restart without rebuilding again', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); await advance(); const operation = (await operationFor(rebuild.deploymentId))!; const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { probeVersion } = await import('../../src/lib/server/integrations/plutonium.js'); const { rconPlutoniumVersion } = await import('../../src/lib/server/integrations/rcon.js'); const actual = await vi.importActual< typeof import('../../src/lib/server/integrations/plutonium.js') >('../../src/lib/server/integrations/plutonium.js'); const backend = await import('../../src/lib/server/backends/native.js'); vi.spyOn(backend, 'nativeHealth').mockResolvedValue({ ok: true, state: 'running' }); const submittedAt = new Date(Date.now() - 1000).toISOString(); db() .prepare('UPDATE server_deployments SET submitted_at=? WHERE id=?') .run(submittedAt, rebuild.deploymentId); db() .prepare('UPDATE restart_rollout SET requested_at=? WHERE operation_id=?') .run(submittedAt, rebuild.deploymentId); vi.mocked(rconPlutoniumVersion).mockResolvedValueOnce({ revision: null, error: 'Version response unavailable' }); vi.mocked(probeVersion).mockImplementationOnce(actual.probeVersion); await acknowledge(rebuild.deploymentId); expect(await detail(rebuild.deploymentId)).toMatchObject({ state: 'awaiting-health', stage: 'Waiting to verify the Plutonium runtime revision.' }); expect( db() .prepare('SELECT healthy_at FROM restart_rollout WHERE operation_id=?') .get(rebuild.deploymentId) ).toEqual({ healthy_at: null }); closeDb(); vi.mocked(probeVersion).mockImplementationOnce(actual.probeVersion); await advance(); expect((await detail(rebuild.deploymentId)).state).toBe('done'); expect((await operationFor(rebuild.deploymentId))!.id).toBe(operation.id); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'" ) .get() ).toEqual({ count: 1 }); }); it('drains by reserving destination capacity, waiting for empty lobbies, and rebuilding once on another host', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js'); const { updateHost, createHost, createEnrollment, enrollHost, reportHostOperation, getHost } = await import('../../src/lib/server/hosts/index.js'); updateHost(hostId, { mode: 'draining' }); const { nativeSelection } = await import('../../src/lib/server/provisioning/native.js'); expect(() => nativeSelection({ hostId, libraryId, image }, 't5')).toThrow(/draining/); const { db } = await import('../../src/lib/server/db/index.js'); const { getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); await processProvisioning(); const migration = db() .prepare( "SELECT id FROM server_deployments WHERE triggered_by='host-drain' AND server=?" ) .get(job.name) as { id: string }; expect((await detail(migration.id)).stage).toContain('No host has capacity'); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'").all() ).toEqual([]); const target = createHost({ name: 'Destination', portMin: 29000, portMax: 29005 }); enrollHost(createEnrollment(target.id).token, inventory()); updateHostLibraryCaches(target.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: getLibraryVersion(libraryId)!.manifestSha256 } ]); remote.players = 2; await processProvisioning(); expect((await detail(migration.id)).stage).toContain('2 player(s)'); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'").all() ).toEqual([]); remote.players = 0; await processProvisioning(); const remove = db() .prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'") .get() as { id: string }; expect(remove).toBeTruthy(); expect( db().prepare('SELECT protocol FROM ec_host_ports WHERE host_id=?').all(target.id) ).toHaveLength(2); expect(() => updateHost(target.id, { mode: 'draining' })).toThrow('incoming'); reportHostOperation(hostId, remove.id, { state: 'unknown' }); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); await processProvisioning(); expect( db().prepare('SELECT host_id FROM native_instances WHERE server=?').get(job.name) ).toEqual({ host_id: hostId }); reportHostOperation(hostId, remove.id, { state: 'completed' }); await processProvisioning(); expect( db() .prepare('SELECT host_id,generation FROM native_instances WHERE server=?') .get(job.name) ).toEqual({ host_id: target.id, generation: 2 }); expect(db().prepare('SELECT * FROM ec_host_ports WHERE host_id=?').all(hostId)).toEqual([]); expect(getHost(hostId)?.mode).toBe('draining'); const { cancelProvisioning } = await import('../../src/lib/server/provisioning/requests.js'); expect(cancelProvisioning(migration.id)).toBe(false); await processProvisioning(); const create = (await operationFor(migration.id))!; expect(create).toMatchObject({ hostId: target.id, kind: 'instance.create', generation: 2, payload: { name: job.name, port: 29000 } }); reportHostOperation(target.id, create.id, { state: 'completed', result: { state: 'running', healthy: true } }); await processProvisioning(); expect((await detail(migration.id)).state).toBe('done'); expect(getHost(hostId)?.mode).toBe('disabled'); expect( db().prepare('SELECT generation FROM native_instances WHERE server=?').get(job.name) ).toEqual({ generation: 2 }); await advance(); expect((await operationFor(migration.id))?.id).toBe(create.id); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'").all() ).toHaveLength(1); expect(getLibraryVersion(libraryId)?.state).toBe('ready'); }); it('reserves migration capacity before source removal and retains it while a replacement awaits startup', async () => { const first = await queue(); await advance(); await acknowledge(first.deploymentId); const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js'); const second = queueCreateServer({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', hostId, libraryId, image, plutoKey: 'second-key' }); if (!second.ok) throw new Error(second.message); await advance(); await acknowledge(second.deploymentId); const { createHost, createEnrollment, enrollHost, updateHost, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); const target = createHost({ name: 'One free slot' }); enrollHost(createEnrollment(target.id).token, { ...inventory(), memoryTotal: 4 * 1024 ** 3, memoryAvailable: 1.75 * 1024 ** 3 }); updateHostLibraryCaches(target.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: getLibraryVersion(libraryId)!.manifestSha256 } ]); updateHost(hostId, { mode: 'draining' }); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); const removals = () => db() .prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'") .all() as { id: string }[]; expect(removals()).toHaveLength(1); expect( db().prepare('SELECT instance_id FROM ec_host_ports WHERE host_id=?').all(target.id) ).toHaveLength(2); const { choosePlacement } = await import('../../src/lib/server/hosts/capacity.js'); expect(() => choosePlacement({ hostId: target.id, libraryId, image })).toThrow( 'memory capacity' ); reportHostOperation(hostId, removals()[0].id, { state: 'completed' }); await advance(); expect(() => choosePlacement({ hostId: target.id, libraryId, image })).toThrow( 'memory capacity' ); expect(removals()).toHaveLength(1); }); it('retries definite migration failures without replaying successful source removal', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { createHost, createEnrollment, enrollHost, updateHost, reportHostOperation, getHost } = await import('../../src/lib/server/hosts/index.js'); const { getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); const target = createHost({ name: 'Destination' }); enrollHost(createEnrollment(target.id).token, inventory()); updateHostLibraryCaches(target.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: getLibraryVersion(libraryId)!.manifestSha256 } ]); updateHost(hostId, { mode: 'draining' }); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); const migration = db() .prepare('SELECT deployment_id,remove_operation_id FROM ec_host_migrations') .get() as { deployment_id: string; remove_operation_id: string }; const expireRetry = (id: string) => db() .prepare('UPDATE ec_host_operations SET updated_at=? WHERE id=?') .run(new Date(Date.now() - 61_000).toISOString(), id); reportHostOperation(hostId, migration.remove_operation_id, { state: 'failed', error: 'Docker unavailable before removal' }); await advance(); expect( db().prepare('SELECT host_id FROM native_instances WHERE server=?').get(job.name) ).toEqual({ host_id: hostId }); expireRetry(migration.remove_operation_id); await advance(); await advance(); const retry = db().prepare('SELECT remove_operation_id FROM ec_host_migrations').get() as { remove_operation_id: string; }; expect(retry.remove_operation_id).not.toBe(migration.remove_operation_id); reportHostOperation(hostId, retry.remove_operation_id, { state: 'completed' }); await advance(); await advance(); const creation = (await operationFor(migration.deployment_id))!; reportHostOperation(target.id, creation.id, { state: 'failed', error: 'Docker unavailable before creation' }); expireRetry(creation.id); await advance(); await advance(); const replacement = (await operationFor(migration.deployment_id))!; expect(replacement.id).not.toBe(creation.id); expect(replacement).toMatchObject({ kind: 'instance.create', hostId: target.id, generation: 2 }); reportHostOperation(target.id, replacement.id, { state: 'completed' }); await advance(); expect(getHost(hostId)?.mode).toBe('disabled'); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.remove'").all() ).toHaveLength(2); }); it('disables an empty draining host and redirects its unplaced launches to another host', async () => { const job = await queue(); const { createHost, createEnrollment, enrollHost, updateHost, getHost } = await import('../../src/lib/server/hosts/index.js'); const { getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); const target = createHost({ name: 'Destination' }); enrollHost(createEnrollment(target.id).token, inventory()); updateHostLibraryCaches(target.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: getLibraryVersion(libraryId)!.manifestSha256 } ]); updateHost(hostId, { mode: 'draining' }); expect(() => updateHost(hostId, { mode: 'active' })).toThrow('finish draining'); await advance(); expect(getHost(hostId)?.mode).toBe('disabled'); expect(await operationFor(job.deploymentId)).toMatchObject({ hostId: target.id, kind: 'instance.create' }); }); it('rejects the removed manual disable API action', async () => { const { PATCH } = await import('../../src/routes/api/hosts/[id]/+server.js'); const response = await PATCH({ params: { id: hostId }, request: new Request(`https://ec.test/api/hosts/${hostId}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ mode: 'disabled' }) }) }); expect(response.status).toBe(400); expect(await response.json()).toMatchObject({ error: expect.stringContaining('disable automatically') }); const { getHost } = await import('../../src/lib/server/hosts/index.js'); expect(getHost(hostId)?.mode).toBe('active'); }); it('builds campaign replacements with requested plugins, configuration, additions and removals', async () => { const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { db } = await import('../../src/lib/server/db/index.js'); for (const path of ['replace.dll', 'remove.dll']) { const file = createScript({ name: path, kind: 'plugin', targetRelPath: path, scopeType: 'game', scopeValue: 't5', content: Buffer.from('promoted plugin') }); db() .prepare('UPDATE scripts SET current_version_id=? WHERE id=?') .run(file.version.id, file.script.id); } const job = await queue(); await advance(); await acknowledge(job.deploymentId); db() .prepare( "INSERT INTO deployments(id,action,started_at) VALUES('files-rebuild','promote',?)" ) .run(new Date().toISOString()); db() .prepare( "INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('files-rebuild',?,4,'pending')" ) .run(job.name); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const changes = [ { kind: 'plugin', relPath: 'replace.dll', content: Buffer.from('requested plugin') }, { kind: 'plugin', relPath: 'remove.dll', content: null }, { kind: 'script', relPath: 'new.gsc', content: Buffer.from('new script') }, { kind: 'config', relPath: 'custom.cfg', content: Buffer.from('set custom 1') } ] as const; for (const change of changes) queueFileChange({ sourceKind: 'campaign', sourceId: 'files-rebuild', server: job.name, inventorySource: 'campaign', ...change }); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const rebuild = requestRestart(job.name, { kind: 'campaign', id: 'files-rebuild' }); await advance(); const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const staged = db() .prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?') .get(rebuild.id) as { stage_operation_id: string }; reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed' }); await advance(); const operation = (await operationFor(rebuild.id))!; expect(operation.kind).toBe('instance.rebuild'); expect(operation.payload).not.toHaveProperty('unhealthyRecovery'); const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const recipe = recipeForDeployment(rebuild.id)!.recipe; for (const change of changes) { const actual = recipe.files.find( (file) => file.kind === change.kind && file.path === change.relPath ); if (change.content === null) expect(actual).toBeUndefined(); else expect(Buffer.from(actual!.content!, 'base64')).toEqual(change.content); } const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{ id: number; path: string; }>; reportHostOperation(hostId, operation.id, { state: 'completed', result: { before: files.map((file) => ({ ...file, contentBase64: null })) } }); remote.state = 'unhealthy'; await advance(); expect((await operationFor(rebuild.id))!.kind).toBe('instance.restart'); await acknowledge(rebuild.id); const recovery = (await operationFor(rebuild.id))!; expect(recovery.kind).toBe('instance.rebuild'); expect(recovery.payload).not.toHaveProperty('stageId'); expect(recovery.payload.files).toEqual(operation.payload.files); remote.state = 'running'; await acknowledge(rebuild.id); expect((await detail(rebuild.id)).state).toBe('done'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(3); }); it('stages campaign bytes while occupied and records agent before images only after safe activation', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); const before = Buffer.from('original bytes on the host'); const after = Buffer.from('new campaign bytes'); db() .prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign','promote',?)") .run(new Date().toISOString()); db() .prepare( "INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign',?,1,'pending')" ) .run(job.name); const fileId = Number( db() .prepare( "INSERT INTO deployment_files(deployment_id,server,verb,rel_path,content_after_hash,snapshot_pending) VALUES('campaign',?,'updated','hud.gsc',?,1)" ) .run(job.name, putFileBlob(after, db())).lastInsertRowid ); const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js'); queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign', server: job.name, kind: 'script', relPath: 'hud.gsc', content: after, inventorySource: 'test', deploymentFileId: fileId }); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const restart = requestRestart(job.name, { kind: 'campaign', id: 'campaign' }); expect(restart.action).toBe('rebuild'); // Upgrade queued work from older controllers, but wait for a capable host. db().prepare("UPDATE server_deployments SET action='restart' WHERE id=?").run(restart.id); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), capabilities: [] }); await advance(); expect(await detail(restart.id)).toMatchObject({ action: 'rebuild', state: 'waiting', stage: expect.stringContaining('Update the host agent') }); expect(await operationFor(restart.id)).toBeNull(); heartbeat(hostId, inventory()); remote.players = 2; await advance(); const staged = db() .prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?') .get(restart.id) as { stage_operation_id: string }; expect(staged.stage_operation_id).toBeTruthy(); const { getHostOperation, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const stage = getHostOperation(staged.stage_operation_id)!; expect(stage.kind).toBe('files.stage'); expect(await operationFor(restart.id)).toBeNull(); const routing = db() .prepare('SELECT payload FROM ec_host_operations WHERE id=?') .get(stage.id) as { payload: string }; expect(JSON.parse(routing.payload)).toEqual({ stageId: restart.id }); const plan = db() .prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?') .get(restart.id) as { files_json: string }; expect(JSON.parse(plan.files_json)[0]).not.toHaveProperty('contentBase64'); const storedPayload = db() .prepare('SELECT payload FROM ec_host_operation_payloads WHERE operation_id=?') .get(stage.id) as { payload: string }; expect(JSON.parse(storedPayload.payload).files[0]).toMatchObject({ contentSha256: createHash('sha256').update(after).digest('hex') }); expect(JSON.parse(storedPayload.payload).files[0]).not.toHaveProperty('contentBase64'); const { closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); const { pendingHostOperations } = await import('../../src/lib/server/hosts/index.js'); expect( pendingHostOperations(hostId).find((operation) => operation.id === stage.id)?.payload ).toEqual(stage.payload); await advance(); expect( ( db() .prepare( 'SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?' ) .get(restart.id) as { stage_operation_id: string } ).stage_operation_id ).toBe(stage.id); expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('pending'); reportHostOperation(hostId, stage.id, { state: 'completed', result: { state: 'staged' } }); await advance(); expect(await operationFor(restart.id)).toBeNull(); remote.players = 0; await advance(); const activation = (await operationFor(restart.id))!; expect(activation).toMatchObject({ kind: 'instance.rebuild', generation: 1, payload: { stageId: restart.id, nextGeneration: 2, files: expect.arrayContaining([ expect.objectContaining({ path: 'runtime/plutonium/storage/t5/raw/scripts/sp/hud.gsc', sha256: createHash('sha256').update(after).digest('hex') }) ]) } }); expect( db() .prepare( 'SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,snapshot_pending FROM deployment_files WHERE id=?' ) .get(fileId) ).toEqual({ content_before: null, snapshot_pending: 1 }); const files = stage.payload.files as Array<{ id: number; path: string }>; reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: [ { id: files[0].id, path: files[0].path, contentBase64: before.toString('base64') } ] } }); await advance(); expect((await detail(restart.id)).state).toBe('done'); expect(stagedFileChanges('campaign', 'campaign')[0].state).toBe('applied'); expect( db() .prepare( 'SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before,snapshot_pending FROM deployment_files WHERE id=?' ) .get(fileId) ).toEqual({ content_before: before, sha256_before: createHash('sha256').update(before).digest('hex'), snapshot_pending: 0 }); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); expect(getServerScript(job.name, 'hud.gsc')?.sha256).toBe( createHash('sha256').update(after).digest('hex') ); }); it('atomically queues mixed-game collections and reserves per-host ports when dispatched', async () => { const t5 = await prepareLibrary('t5'); const t6 = await prepareLibrary('t6'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const blueprint = saveBlueprint({ name: 'Both games', entries: [ { game: 't5', mapSlug: 'theater', count: 2 }, { game: 't6', mapSlug: 'town', count: 1 } ] }); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const preview = previewCollection(blueprint.id, 'fill', { hostId, libraryIds: { t5: t5.id, t6: t6.id }, image }); expect(preview.ready).toBe(true); expect(preview.servers.map((server) => server.port)).toEqual([null, null, null]); const queued = queueCollection({ previewId: preview.id, keys: Object.fromEntries( preview.servers.map((server) => [server.name!, 'key-' + server.name]) ) }); expect(queueCollection({ previewId: preview.id }).collectionId).toBe(queued.collectionId); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT id FROM native_instances').all()).toEqual([]); expect(db().prepare('SELECT instance_id FROM ec_host_ports').all()).toEqual([]); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 4 }); await advance(); const instances = db() .prepare('SELECT host_id,library_id,port FROM native_instances ORDER BY port') .all(); expect(instances).toEqual([ { host_id: hostId, library_id: t5.id, port: 28960 }, { host_id: hostId, library_id: t5.id, port: 28961 }, { host_id: hostId, library_id: t6.id, port: 28962 } ]); const { createHost, createEnrollment, enrollHost, hostPortAvailable } = await import('../../src/lib/server/hosts/index.js'); const secondHost = createHost({ name: 'Second Debian' }); enrollHost(createEnrollment(secondHost.id).token, inventory()); const { updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); updateHostLibraryCaches(secondHost.id, [ { libraryVersionId: t5.id, status: 'ready', manifestSha256: t5.manifestSha256 } ]); const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js'); const second = queueCreateServer({ game: 't5', gameMode: 'zombie', mapSlug: 'moon', hostId: secondHost.id, libraryId: t5.id, image, plutoKey: 'second-host-key' }); expect(second.ok).toBe(true); if (!second.ok) throw new Error(second.message); expect(second.port).toBeNull(); await advance(); expect((await operationFor(second.deploymentId))?.payload.port).toBe(28960); expect(hostPortAvailable(hostId, 28960)).toBe(false); expect(hostPortAvailable(secondHost.id, 28960)).toBe(false); expect(db().prepare('SELECT COUNT(*) AS count FROM ec_host_ports').get()).toEqual({ count: 8 }); }); it('reads remote instance files through the agent including missing files and epoch timestamps', async () => { const job = await queue(); await advance(); const backend = await import('../../src/lib/server/backends/native.js'); const remoteRead = vi.mocked(backend.nativeRequest); remoteRead.mockImplementation(async (_instance, kind, payload) => { if (payload?.path === 'missing') return (kind === 'files.read' ? { contentBase64: null } : null) as never; if (kind === 'files.stat') return { size: 15, mtime: 1_790_000_000, isFile: true, isDirectory: false } as never; if (kind === 'files.read') return { contentBase64: Buffer.from('agent file bytes').toString('base64') } as never; if (kind === 'files.list') return { entries: [{ name: 'configs', isDirectory: true, isFile: false }] } as never; throw new Error('Unexpected file operation'); }); const files = await import('../../src/lib/server/backends/files.js'); const root = join(fixture.t5, job.name); expect((await files.readFile(join(root, 'configs/server.cfg'))).toString()).toBe( 'agent file bytes' ); const info = await files.stat(join(root, 'configs/server.cfg')); expect(info.mtime.getTime()).toBe(1_790_000_000_000); expect(info.isFile()).toBe(true); expect(await files.exists(join(root, 'missing'))).toBe(false); await expect(files.readFile(join(root, 'missing'))).rejects.toMatchObject({ code: 'ENOENT' }); const entries = await files.readdir(root, { withFileTypes: true }); expect(entries[0].name).toBe('configs'); expect(entries[0].isDirectory()).toBe(true); expect(remoteRead).toHaveBeenCalledWith( expect.objectContaining({ server: job.name }), 'files.read', { path: 'configs/server.cfg' } ); const { stat } = await import('node:fs/promises'); await expect(stat(root)).rejects.toMatchObject({ code: 'ENOENT' }); }); it('retires an agent-confirmed missing container without waiting on nonexistent RCON', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const removal = destroyServer(job.name); if (!removal.ok || !removal.deploymentId) throw new Error('Removal was not queued'); const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconStatusResult).mockClear(); remote.state = 'missing'; await advance(); expect((await operationFor(removal.deploymentId))?.kind).toBe('instance.remove'); expect(rconStatusResult).not.toHaveBeenCalled(); await acknowledge(removal.deploymentId); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)).toBeNull(); expect((await detail(removal.deploymentId)).state).toBe('done'); }); it('releases a failed activation slot without recording fictitious file changes', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( "INSERT INTO deployments(id,action,started_at) VALUES('preflight-failed','promote',?)" ) .run(new Date().toISOString()); db() .prepare( "INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('preflight-failed',?,1,'pending')" ) .run(job.name); const fileId = Number( db() .prepare( "INSERT INTO deployment_files(deployment_id,server,verb,rel_path) VALUES('preflight-failed',?,'updated','hud.gsc')" ) .run(job.name).lastInsertRowid ); const { queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js'); queueFileChange({ sourceKind: 'campaign', sourceId: 'preflight-failed', server: job.name, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('new bytes'), inventorySource: 'test', deploymentFileId: fileId }); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const restart = requestRestart(job.name, { kind: 'campaign', id: 'preflight-failed' }); await advance(); const stage = db() .prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?') .get(restart.id) as { stage_operation_id: string }; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, stage.stage_operation_id, { state: 'completed', result: { state: 'staged' } }); await advance(); const activation = (await operationFor(restart.id))!; expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('applying'); reportHostOperation(hostId, activation.id, { state: 'failed', error: 'Target changed after campaign review; no activation occurred' }); await advance(); expect((await detail(restart.id)).state).toBe('failed'); expect(stagedFileChanges('campaign', 'preflight-failed')[0].state).toBe('pending'); expect( db() .prepare('SELECT COUNT(*) AS count FROM restart_rollout WHERE healthy_at IS NULL') .get() ).toEqual({ count: 0 }); expect( db() .prepare( 'SELECT (SELECT content FROM file_blobs WHERE sha256=content_before_hash) AS content_before,sha256_before FROM deployment_files WHERE id=?' ) .get(fileId) ).toEqual({ content_before: null, sha256_before: null }); const { getServerScript } = await import('../../src/lib/server/fleet/inventory.js'); expect(getServerScript(job.name, 'hud.gsc')).toBeUndefined(); }); it('cancels an unplaced native launch locally without reserving ports or submitting a create', async () => { const job = await queue(); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)).toBeNull(); const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const cancelled = destroyServer(job.name); expect(cancelled.ok).toBe(true); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect(nativeInstance(job.name)).toBeNull(); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind LIKE 'instance.%'" ) .get() ).toEqual({ count: 0 }); const { getLibraryVersion } = await import('../../src/lib/server/game-files/index.js'); expect(getLibraryVersion(libraryId)?.state).toBe('ready'); const { getServerKey, serverKeyPool } = await import('../../src/lib/server/fleet/server-keys.js'); expect(getServerKey(job.name)).toBeUndefined(); expect(serverKeyPool('t5')).toEqual({ total: 1, available: 1 }); }); }); describe('automatic capacity admission and placement', () => { const input = { game: 't5' as const, gameMode: 'zombie' as const, mapSlug: 'theater', plutoKey: 'key' }; async function secondHost(overrides = {}) { const api = await import('../../src/lib/server/hosts/index.js'); const libs = await import('../../src/lib/server/game-files/index.js'); const host = api.createHost({ name: 'Second host', publicAddress: 'second.example', portMin: 28960, portMax: 28970 }); api.updateHost(host.id, { publicAddress: 'second.example' }); api.enrollHost(api.createEnrollment(host.id).token, { ...inventory(), ...overrides }); const version = libs.getLibraryVersion(libraryId)!; libs.updateHostLibraryCaches(host.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: version.manifestSha256 } ]); return host; } it('admits without a host, then selects the least loaded eligible host and its network defaults', async () => { await prepareLibrary(); const other = await secondHost({ cpuUsagePercent: 0 }); const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const { recipeForDeployment } = await import('../../src/lib/server/fleet/launch-recipes.js'); const result = queueCreateServer(input); expect(result.ok).toBe(true); if (!result.ok) return; expect(nativeInstance(result.name)).toBeNull(); expect(result.port).toBeNull(); await advance(); expect((await operationFor(result.deploymentId))?.hostId).toBe(other.id); expect(nativeInstance(result.name)).toMatchObject({ host_id: other.id, public_address: 'second.example' }); expect(recipeForDeployment(result.deploymentId)?.recipe.native?.hostId).toBe(other.id); // Once submitted, even a now-offline host cannot trigger another placement. const { db } = await import('../../src/lib/server/db/index.js'); db().prepare("UPDATE ec_hosts SET last_heartbeat='2000-01-01' WHERE id=?").run(other.id); await advance(); expect((await operationFor(result.deploymentId))?.hostId).toBe(other.id); expect( db().prepare("SELECT id FROM ec_host_operations WHERE kind='instance.create'").all() ).toHaveLength(1); }); it('moves an unsubmitted reservation off an offline host and preserves explicit network overrides', async () => { await prepareLibrary(); await secondHost(); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const result = queueNativeCreation({ ...input, publicAddress: 'nat.example', publicPort: 30000 }); expect(result.ok).toBe(true); if (!result.ok) return; db().prepare("UPDATE ec_hosts SET last_heartbeat='2000-01-01' WHERE id=?").run(hostId); await advance(); expect(nativeInstance(result.name)).toMatchObject({ public_address: 'nat.example', public_port: 30000 }); expect((await operationFor(result.deploymentId))?.hostId).not.toBe(hostId); expect(db().prepare('SELECT * FROM ec_host_ports WHERE host_id=?').all(hostId)).toEqual([]); }); it.each([ { cpuUsagePercent: undefined }, { memoryAvailable: undefined }, { cpuCount: 0 }, { cpuUsagePercent: 99 }, { memoryAvailable: 1024 ** 3 } ])('accepts an unplaced job and waits for unavailable resources: %j', async (overrides) => { await prepareLibrary(); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), ...overrides }); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { db } = await import('../../src/lib/server/db/index.js'); const queued = queueNativeCreation(input); expect(queued).toMatchObject({ ok: true, port: null }); if (!queued.ok) throw new Error(queued.message); await advance(); expect(await operationFor(queued.deploymentId)).toBeNull(); expect((await detail(queued.deploymentId)).stage).toContain('capacity'); expect(db().prepare('SELECT * FROM native_instances').all()).toEqual([]); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); }); it('rechecks preview admission without binding the request to the preview host or port', async () => { await prepareLibrary(); const other = await secondHost(); const { updateHost } = await import('../../src/lib/server/hosts/index.js'); updateHost(other.id, { portMin: 30000, portMax: 30002 }); const { previewNativeCreation, queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const preview = previewNativeCreation(input); const { db } = await import('../../src/lib/server/db/index.js'); db().prepare("UPDATE ec_hosts SET last_heartbeat='2000-01-01' WHERE id=?").run(hostId); const result = queueNativeCreation({ ...input, previewId: preview.id }); expect(result).toMatchObject({ ok: true, name: preview.name, port: null }); expect(queueNativeCreation({ ...input, previewId: preview.id })).toEqual(result); if (!result.ok) throw new Error(result.message); await advance(); expect((await operationFor(result.deploymentId))?.payload.port).toBe(30000); }); it('queues the whole collection and waits when capacity shrinks after its preview', async () => { await prepareLibrary(); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const blueprint = saveBlueprint({ name: 'Two lobbies', entries: [{ game: 't5', mapSlug: 'theater', count: 2 }] }); const preview = previewCollection(blueprint.id); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), cpuUsagePercent: 100 }); const result = queueCollection({ previewId: preview.id, keys: Object.fromEntries( preview.servers.map((server) => [server.name!, 'key-' + server.name]) ) }); expect(result).toMatchObject({ ok: true, collectionId: expect.any(String) }); await advance(); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT * FROM native_instances').all()).toEqual([]); expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); expect(db().prepare('SELECT * FROM server_launch_collections').all()).toHaveLength(1); expect(db().prepare('SELECT * FROM native_creation_intents').all()).toHaveLength(2); }); it.each([ { resource: 'CPU', measurements: { cpuCount: 4, cpuUsagePercent: 82.5 }, over: { cpuUsagePercent: 82.51 } }, { resource: 'memory', measurements: { memoryAvailable: 1024 ** 3 + 500_000_000 }, over: { memoryAvailable: 1024 ** 3 + 499_999_999 } } ])('reserves only the requested host $resource headroom', async ({ measurements, over }) => { await prepareLibrary(); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { choosePlacement } = await import('../../src/lib/server/hosts/capacity.js'); const options = { hostId, libraryId, image }; heartbeat(hostId, { ...inventory(), ...measurements }); // Includes the new server's 0.5-core / 1-GiB bootstrap reservation. expect(choosePlacement(options).host.id).toBe(hostId); expect(choosePlacement({ ...options, thorough: true }).host.id).toBe(hostId); heartbeat(hostId, { ...inventory(), ...measurements, ...over }); expect(() => choosePlacement(options)).toThrow('capacity'); expect(() => choosePlacement({ ...options, thorough: true })).toThrow('capacity'); }); it('dispatches a waiting instance after calibration lowers the cold-start allowance', async () => { const first = await queue(); await advance(); await acknowledge(first.deploymentId); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const { recordInstanceResources, capacityCalibration } = await import('../../src/lib/server/hosts/calibration.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const measured = { ...inventory(), cpuCount: 1, cpuUsagePercent: 50 }; heartbeat(hostId, measured); const next = queueNativeCreation({ ...input, plutoKey: 'next-key' }); expect(next.ok).toBe(true); if (!next.ok) throw new Error(next.message); await advance(); expect(await operationFor(next.deploymentId)).toBeNull(); const instance = nativeInstance(first.name)!; for (let minute = 0; minute < 25; minute++) { recordInstanceResources( hostId, { ...measured, instanceResources: [ { instanceId: instance.id, generation: 1, uptimeSeconds: minute < 4 ? minute * 60 : 600, cpuCores: 0.1, memoryBytes: 256 * 1024 ** 2 } ] }, new Date(Date.now() - (25 - minute) * 60_000).toISOString() ); } expect(capacityCalibration(hostId).startup.source).toBe('host'); await advance(); expect(await operationFor(next.deploymentId)).not.toBeNull(); }); it('queues a collection at running demand and releases startup reservations as live usage arrives', async () => { await prepareLibrary(); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const GiB = 1024 ** 3; const measured = { ...inventory(), cpuCount: 4, cpuUsagePercent: 5, memoryTotal: 4 * GiB, memoryAvailable: 3.75 * GiB }; heartbeat(hostId, measured); saveFleetSettings({ ...fleetSettings(), createConcurrency: 3 }); const insert = db().prepare(`INSERT INTO ec_resource_allowances (scope,phase,cpu_cores,memory_bytes,samples,window_start,observed_at,computed_at) VALUES(?,?,?,?,30,?,?,?)`); const now = new Date().toISOString(); const start = new Date(Date.now() - 30 * 60_000).toISOString(); insert.run(hostId, 'startup', 2, 2 * GiB, start, now, now); insert.run(hostId, 'running', 0.2, GiB / 4, start, now, now); const blueprint = saveBlueprint({ name: 'Staggered lobbies', entries: [{ game: 't5', mapSlug: 'theater', count: 3 }] }); const preview = previewCollection(blueprint.id); expect(preview.ready).toBe(true); expect( queueCollection({ previewId: preview.id, keys: Object.fromEntries( preview.servers.map((server) => [server.name!, 'key-' + server.name]) ) }).ok ).toBe(true); await advance(); const creates = () => db() .prepare("SELECT instance_id FROM ec_host_operations WHERE kind='instance.create'") .all() as { instance_id: string }[]; expect(creates()).toHaveLength(1); await advance(); expect(creates()).toHaveLength(1); // No heartbeat yet: the first launch still reserves startup resources. heartbeat(hostId, { ...measured, cpuUsagePercent: 10, memoryAvailable: 3.5 * GiB, instanceResources: [ { instanceId: creates()[0].instance_id, generation: 1, uptimeSeconds: 60, cpuCores: 0.2, memoryBytes: GiB / 4 } ] }); await advance(); expect(creates()).toHaveLength(2); // Its actual usage replaces the peak even before health verification. }); it('reserves queued lobby identities without host resources and releases them on cancellation', async () => { await prepareLibrary(); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), cpuCount: 1, cpuUsagePercent: 0 }); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const first = queueNativeCreation(input); expect(first.ok).toBe(true); if (!first.ok) return; const second = queueNativeCreation({ ...input, plutoKey: 'second-key' }); expect(second).toMatchObject({ ok: true, name: 't5_zombie_theater_2', port: null }); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT id FROM native_instances').all()).toEqual([]); expect(db().prepare('SELECT instance_id FROM ec_host_ports').all()).toEqual([]); cancelCreation(first.deploymentId); expect(queueNativeCreation(input)).toMatchObject({ ok: true, name: first.name, port: null }); }); it.each(['memory', 'cpu'] as const)( 'dispatches immediately after %s recovers instead of waiting for five-minute history', async (resource) => { await prepareLibrary(); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { db } = await import('../../src/lib/server/db/index.js'); const GiB = 1024 ** 3; // A recently busy 4 GiB host is now mostly empty, as in the reported // ec-2 inventory. Admission must not pass only to stall in dispatch. heartbeat(hostId, { ...inventory(), cpuCount: 4, memoryTotal: 4 * GiB, memoryAvailable: Math.floor(3.8 * GiB) }); for (let minute = 1; minute <= 4; minute++) db() .prepare( `INSERT INTO ec_host_resource_samples (host_id,sampled_at,cpu_count,cpu_usage_percent,memory_total,memory_available) VALUES(?,?,4,?,?,?)` ) .run( hostId, new Date(Date.now() - minute * 60_000).toISOString(), resource === 'cpu' ? 100 : 10, 4 * GiB, resource === 'memory' ? GiB / 8 : Math.floor(3.8 * GiB) ); const jobs = Array.from({ length: 3 }, (_, index) => queueNativeCreation({ ...input, plutoKey: `key-${index}` }) ); expect(jobs.every((job) => job.ok)).toBe(true); await advance(); expect( await operationFor(jobs[0].ok ? jobs[0].deploymentId : 'missing') ).not.toBeNull(); } ); it('dispatches one queued launch when learned startup costs outgrow the original batch reservation', async () => { await prepareLibrary(); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 3 }); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { db } = await import('../../src/lib/server/db/index.js'); const GiB = 1024 ** 3; heartbeat(hostId, { ...inventory(), cpuCount: 4, memoryTotal: 4 * GiB, memoryAvailable: Math.floor(3.8 * GiB) }); const jobs = Array.from({ length: 3 }, (_, index) => queueNativeCreation({ ...input, plutoKey: `key-${index}` }) ); expect(jobs.every((job) => job.ok)).toBe(true); // Learning increases the startup allowance after admission. All three // no longer fit concurrently, but this otherwise empty host fits one. db() .prepare( `INSERT INTO ec_resource_allowances (scope,phase,cpu_cores,memory_bytes,samples,window_start,observed_at,computed_at) VALUES(?,'startup',0.5,?,3,?,?,?)` ) .run( hostId, 2 * GiB, new Date(Date.now() - 180_000).toISOString(), new Date().toISOString(), new Date().toISOString() ); await advance(); const operations = await Promise.all( jobs.map((job) => operationFor(job.ok ? job.deploymentId : 'missing')) ); expect(operations.filter(Boolean)).toHaveLength(1); expect(operations[0]?.kind).toBe('instance.create'); // The dispatched operation now consumes capacity and blocks the others, // even though the next host heartbeat has not observed it yet. await advance(); expect( db().prepare("SELECT * FROM ec_host_operations WHERE kind='instance.create'").all() ).toHaveLength(1); const later = queueNativeCreation(input); expect(later.ok).toBe(true); if (!later.ok) throw new Error(later.message); await advance(); expect(await operationFor(later.deploymentId)).toBeNull(); }); it('does not charge a starting container twice when its usage is already in the heartbeat', async () => { await prepareLibrary(); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 2 }); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const GiB = 1024 ** 3; const host = { ...inventory(), cpuCount: 4, memoryTotal: 3 * GiB, memoryAvailable: 2.8 * GiB }; heartbeat(hostId, { ...host, memoryAvailable: Math.floor(host.memoryAvailable) }); const first = queueNativeCreation(input); expect(first.ok).toBe(true); if (!first.ok) return; await advance(); const instance = nativeInstance(first.name)!; // 0.2 GiB OS + 0.8 GiB container are already in host usage. // Adding a new 1 GiB server fits below 2.53 GiB usable memory. heartbeat(hostId, { ...host, memoryAvailable: 2 * GiB, instanceResources: [ { instanceId: instance.id, generation: instance.generation, uptimeSeconds: 60, cpuCores: 0.1, memoryBytes: Math.floor(0.8 * GiB) } ] }); const second = queueNativeCreation({ ...input, plutoKey: 'second-key' }); expect(second).toMatchObject({ ok: true }); await advance(); expect(await operationFor(second.ok ? second.deploymentId : 'missing')).not.toBeNull(); }); it.each(['missing', 'old-generation'] as const)( 'keeps full reservations for a submitted launch with %s measurements', async (evidence) => { await prepareLibrary(); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 2 }); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const GiB = 1024 ** 3; heartbeat(hostId, { ...inventory(), memoryTotal: 3 * GiB, memoryAvailable: Math.floor(2.8 * GiB) }); const first = queueNativeCreation(input); expect(first.ok).toBe(true); if (!first.ok) return; await advance(); const instance = nativeInstance(first.name)!; heartbeat(hostId, { ...inventory(), memoryTotal: 3 * GiB, memoryAvailable: 2 * GiB, instanceResources: evidence === 'missing' ? [] : [ { instanceId: instance.id, generation: instance.generation + 1, uptimeSeconds: 60, cpuCores: 0.1, memoryBytes: Math.floor(0.8 * GiB) } ] }); const next = queueNativeCreation({ ...input, plutoKey: 'next-key' }); expect(next.ok).toBe(true); if (!next.ok) throw new Error(next.message); await advance(); expect(await operationFor(next.deploymentId)).toBeNull(); expect((await detail(next.deploymentId)).stage).toContain( '3.00 GiB projected, 2.53 GiB usable' ); } ); it('waits when capacity disappears after admission without sending a create command', async () => { await prepareLibrary(); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const first = queueNativeCreation(input); expect(first.ok).toBe(true); if (!first.ok) return; const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), cpuUsagePercent: 100 }); await advance(); expect(await operationFor(first.deploymentId)).toBeNull(); expect((await detail(first.deploymentId)).stage).toContain('capacity'); }); it('uses recent history at placement while keeping admission to current inventory', async () => { await prepareLibrary(); const other = await secondHost({ cpuUsagePercent: 30 }); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare( `INSERT INTO ec_host_resource_samples(host_id,sampled_at,cpu_count,cpu_usage_percent,memory_total,memory_available) VALUES(?,?,16,95,?,?)` ) .run( hostId, new Date(Date.now() - 60_000).toISOString(), 64 * 1024 ** 3, 60 * 1024 ** 3 ); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); const first = queueNativeCreation(input); expect(first.ok).toBe(true); if (!first.ok) return; await advance(); expect((await operationFor(first.deploymentId))?.hostId).toBe(other.id); }); it('persists nullable CPU/memory samples and prunes old history per host', async () => { await prepareLibrary(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); db() .prepare( "INSERT INTO ec_host_resource_samples(host_id,sampled_at) VALUES(?,'2000-01-01')" ) .run(hostId); heartbeat(hostId, { ...inventory(), cpuUsagePercent: undefined }); closeDb(); const rows = db() .prepare('SELECT * FROM ec_host_resource_samples WHERE host_id=? ORDER BY sampled_at') .all(hostId) as { cpu_usage_percent: number | null; memory_available: number; sampled_at: string; }[]; expect(rows.at(-1)).toMatchObject({ cpu_usage_percent: null, memory_available: 60 * 1024 ** 3 }); expect(rows.some((row) => row.sampled_at === '2000-01-01')).toBe(false); }); it('checks both port protocols and skips draining hosts', async () => { await prepareLibrary(); const other = await secondHost(); const { reserveHostPort, updateHost } = await import('../../src/lib/server/hosts/index.js'); const { queueNativeCreation } = await import('../../src/lib/server/provisioning/native.js'); for (let port = 28960; port <= 28970; port++) reserveHostPort(hostId, 'external-' + port, { port, protocol: 'tcp' }); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const result = queueNativeCreation(input); expect(result.ok).toBe(true); if (!result.ok) return; expect(nativeInstance(result.name)).toBeNull(); await advance(); expect(nativeInstance(result.name)?.host_id).toBe(other.id); updateHost(other.id, { mode: 'draining' }); const next = queueNativeCreation({ ...input, plutoKey: 'next-key' }); expect(next.ok).toBe(true); if (!next.ok) throw new Error(next.message); await advance(); expect(await operationFor(next.deploymentId)).toBeNull(); }); it('spreads collection dispatch across hosts, allowing the same port on different hosts', async () => { await prepareLibrary(); await secondHost({ cpuCount: 1, cpuUsagePercent: 0 }); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), cpuCount: 1, cpuUsagePercent: 0 }); const { saveBlueprint } = await import('../../src/lib/server/fleet/launch-recipes.js'); const { previewCollection, queueCollection } = await import('../../src/lib/server/fleet/collections.js'); const blueprint = saveBlueprint({ name: 'Two lobbies', entries: [{ game: 't5', mapSlug: 'theater', count: 2 }] }); const preview = previewCollection(blueprint.id); expect(preview.ready).toBe(true); expect(preview.servers.map((server) => server.port)).toEqual([null, null]); expect( queueCollection({ previewId: preview.id, keys: Object.fromEntries( preview.servers.map((server) => [server.name!, 'key-' + server.name]) ) }).ok ).toBe(true); const { db } = await import('../../src/lib/server/db/index.js'); expect(db().prepare('SELECT id FROM native_instances').all()).toEqual([]); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 2 }); await advance(); // Concurrent ranking can select the same host before the first // reservation commits. The held job retries against the new usage. await advance(); expect(db().prepare('SELECT DISTINCT host_id FROM native_instances').all()).toHaveLength(2); expect(db().prepare('SELECT port FROM native_instances').all()).toEqual([ { port: 28960 }, { port: 28960 } ]); }); }); describe('native verification resume and explicit failed-creation retry', () => { async function failedCreation(state: 'failed' | 'interrupted' = 'failed') { const job = await queue(); await advance(); const original = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, original.id, { state: 'failed', error: 'Host runtime preflight failed' }); await advance(); if (state === 'interrupted') { const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare("UPDATE server_deployments SET state='interrupted' WHERE id=?") .run(job.deploymentId); } return { job, original }; } it.each(['failed', 'interrupted'] as const)( 'retries a %s creation through a new rebuild while retaining the original failure', async (state) => { const { job, original } = await failedCreation(state); const history = await import('../../src/lib/server/provisioning/history.js'); expect(history.getDeployment(job.deploymentId)).toMatchObject({ canResumeVerification: false, canRetryWithRebuild: true }); const { POST: resume } = await import('../../src/routes/api/provisioning/[id]/resume/+server.js'); expect(resume({ params: { id: job.deploymentId } }).status).toBe(409); const { POST: retry } = await import('../../src/routes/api/provisioning/[id]/retry-rebuild/+server.js'); const response = await retry({ params: { id: job.deploymentId } }); expect(response.status).toBe(202); const next = (await response.json()) as { ok: boolean; deploymentId: string }; expect(next.ok).toBe(true); expect(next.deploymentId).not.toBe(job.deploymentId); expect(await detail(next.deploymentId)).toMatchObject({ action: 'rebuild', state: 'waiting', server: job.name }); expect(history.getDeployment(job.deploymentId)).toMatchObject({ canRetryWithRebuild: false, deployment: { state, error: 'Host runtime preflight failed' }, progress: { recovery: { state: 'transferred', operationId: next.deploymentId } } }); expect((await retry({ params: { id: job.deploymentId } })).status).toBe(409); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare('SELECT COUNT(*) AS count FROM server_deployments WHERE server=?') .get(job.name) ).toEqual({ count: 2 }); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)).toMatchObject({ id: original.instanceId, generation: 1, port: original.payload.port }); remote.state = 'missing'; await advance(); const rebuilding = (await operationFor(next.deploymentId))!; expect(rebuilding).toMatchObject({ kind: 'instance.rebuild', generation: 1, instanceId: original.instanceId, payload: { name: job.name, port: original.payload.port, nextGeneration: 2, env: { PLUTO_SERVER_KEY: 'saved-key' } } }); expect(rebuilding.id).not.toBe(original.id); expect(nativeInstance(job.name)?.generation).toBe(1); remote.state = 'running'; await acknowledge(next.deploymentId); expect((await detail(next.deploymentId)).state).toBe('done'); expect(nativeInstance(job.name)?.generation).toBe(2); // An interrupted original is reconciled to its already-failed receipt // on the next worker pass; the failure itself is never cleared. expect(await detail(job.deploymentId)).toMatchObject({ state: 'failed', error: 'Host runtime preflight failed' }); expect(await operationFor(job.deploymentId)).toMatchObject({ id: original.id, state: 'failed' }); expect( db() .prepare( "SELECT kind FROM ec_host_operations WHERE kind IN ('instance.create','instance.rebuild','instance.restart') ORDER BY rowid" ) .all() ).toEqual([{ kind: 'instance.create' }, { kind: 'instance.rebuild' }]); } ); it.each([ ['create', false], ['create', true], ['rebuild', false], ['rebuild', true], ['recovery-restart', false], ['recovery-restart', true], ['recovery-rebuild', false], ['recovery-rebuild', true] ] as const)( 'resumes completed %s verification with the same command (receipt applied=%s)', async (action, applied) => { let job = await queue(); await advance(); if (action === 'rebuild') { await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); job = rebuild; await advance(); } else if (action.startsWith('recovery')) { remote.state = 'unhealthy'; await acknowledge(job.deploymentId); if (action === 'recovery-rebuild') await acknowledge(job.deploymentId); } const operation = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); remote.state = 'starting'; reportHostOperation(hostId, operation.id, { state: 'completed' }); if (applied) await advance(); const history = await import('../../src/lib/server/provisioning/history.js'); history.finishDeployment(job.deploymentId, { ok: false, message: 'Verification interrupted for inspection' }); expect(history.getDeployment(job.deploymentId)).toMatchObject({ canResumeVerification: true, canRetryWithRebuild: false }); const { db } = await import('../../src/lib/server/db/index.js'); const commands = db() .prepare( "SELECT id FROM ec_host_operations WHERE kind LIKE 'instance.%' ORDER BY rowid" ) .all(); const { POST } = await import('../../src/routes/api/provisioning/[id]/resume/+server.js'); expect(POST({ params: { id: job.deploymentId } }).status).toBe(202); remote.state = 'running'; await advance(); expect((await detail(job.deploymentId)).state).toBe('done'); expect((await operationFor(job.deploymentId))!.id).toBe(operation.id); expect( db() .prepare( "SELECT id FROM ec_host_operations WHERE kind LIKE 'instance.%' ORDER BY rowid" ) .all() ).toEqual(commands); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe( operation.generation! + (operation.kind === 'instance.rebuild' ? 1 : 0) ); expect(history.getDeployment(job.deploymentId)!.events).toContainEqual( expect.objectContaining({ kind: 'outcome', message: 'Failed: Verification interrupted for inspection' }) ); } ); it.each(['pending', 'accepted', 'running', 'unknown', 'failed'] as const)( 'does not offer verification of a %s command or replay an ambiguous result', async (state) => { const job = await queue(); await advance(); const operation = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); if (state !== 'pending') reportHostOperation(hostId, operation.id, { state }); const history = await import('../../src/lib/server/provisioning/history.js'); history.finishDeployment(job.deploymentId, { ok: false, message: 'Controller observation interrupted' }); expect(history.getDeployment(job.deploymentId)).toMatchObject({ canResumeVerification: false, canRetryWithRebuild: state === 'failed' }); expect(history.resumeVerification(job.deploymentId)).toBe(false); if (state !== 'failed') { const { requestRetryWithRebuild } = await import('../../src/lib/server/provisioning/requests.js'); expect(requestRetryWithRebuild(job.deploymentId)).toMatchObject({ ok: false, status: 409 }); } const { db } = await import('../../src/lib/server/db/index.js'); if (state === 'unknown') db() .prepare("UPDATE server_deployments SET state='interrupted' WHERE id=?") .run(job.deploymentId); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(operation.id); expect( db() .prepare( "SELECT id FROM ec_host_operations WHERE kind IN ('instance.create','instance.restart','instance.rebuild')" ) .all() ).toEqual([{ id: operation.id }]); } ); it.each([ 'instance generation', 'command generation', 'command instance', 'command host', 'retired instance', 'server port', 'newer work', 'creation cleanup', 'recovery attempt' ])('refuses to retry a failed creation after its %s changes', async (change) => { const { job, original } = await failedCreation(); const { db } = await import('../../src/lib/server/db/index.js'); if (change === 'instance generation') db() .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(job.name); if (change === 'command generation') db() .prepare('UPDATE ec_host_operations SET generation=generation+1 WHERE id=?') .run(original.id); if (change === 'command instance') db() .prepare( "UPDATE ec_host_operations SET instance_id='different-instance' WHERE id=?" ) .run(original.id); if (change === 'command host') { const { createHost } = await import('../../src/lib/server/hosts/index.js'); db() .prepare('UPDATE ec_host_operations SET host_id=? WHERE id=?') .run(createHost({ name: 'Different host' }).id, original.id); } if (change === 'retired instance') db() .prepare('UPDATE native_instances SET retired_at=? WHERE server=?') .run(new Date().toISOString(), job.name); if (change === 'server port') db().prepare('UPDATE servers SET game_port=game_port+1 WHERE name=?').run(job.name); if (change === 'newer work') { const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js'); beginDeployment(job.name, 'restart'); } if (change === 'creation cleanup') { const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); expect(cancelCreation(job.deploymentId)).toEqual(expect.any(String)); } if (change === 'recovery attempt') db() .prepare( "UPDATE native_provisioning SET recovery_step='restart' WHERE deployment_id=?" ) .run(job.deploymentId); const history = await import('../../src/lib/server/provisioning/history.js'); const { requestRetryWithRebuild } = await import('../../src/lib/server/provisioning/requests.js'); expect(history.getDeployment(job.deploymentId)?.canRetryWithRebuild).toBe(false); expect(requestRetryWithRebuild(job.deploymentId)).toMatchObject({ ok: false, status: 409 }); expect( db().prepare("SELECT id FROM server_deployments WHERE action='rebuild'").all() ).toEqual([]); }); it.each([ 'instance generation', 'command generation', 'command instance', 'command host', 'retired instance', 'server port', 'newer work' ])('refuses to resume a completed creation after its %s changes', async (change) => { const job = await queue(); await advance(); const original = (await operationFor(job.deploymentId))!; const { reportHostOperation, createHost } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, original.id, { state: 'completed' }); const history = await import('../../src/lib/server/provisioning/history.js'); history.finishDeployment(job.deploymentId, { ok: false, message: 'Verification interrupted' }); const { db } = await import('../../src/lib/server/db/index.js'); if (change === 'instance generation') db() .prepare('UPDATE native_instances SET generation=generation+1 WHERE server=?') .run(job.name); if (change === 'command generation') db() .prepare('UPDATE ec_host_operations SET generation=generation+1 WHERE id=?') .run(original.id); if (change === 'command instance') db() .prepare( "UPDATE ec_host_operations SET instance_id='different-instance' WHERE id=?" ) .run(original.id); if (change === 'command host') db() .prepare('UPDATE ec_host_operations SET host_id=? WHERE id=?') .run(createHost({ name: 'Different host' }).id, original.id); if (change === 'retired instance') db() .prepare('UPDATE native_instances SET retired_at=? WHERE server=?') .run(new Date().toISOString(), job.name); if (change === 'server port') db().prepare('UPDATE servers SET game_port=game_port+1 WHERE name=?').run(job.name); if (change === 'newer work') history.beginDeployment(job.name, 'restart'); expect(history.getDeployment(job.deploymentId)?.canResumeVerification).toBe(false); expect(history.resumeVerification(job.deploymentId)).toBe(false); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); }); }); describe('unhealthy recovery within native create and rebuild operations', () => { async function launch(action: 'create' | 'rebuild') { const job = await queue(); await advance(); if (action === 'create') return job; await acknowledge(job.deploymentId); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(job.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); await advance(); return rebuild; } it.each(['create', 'rebuild'] as const)( 'recovers an unhealthy %s with one restart then a rebuild using the original launch payload', async (action) => { const { config } = await import('../../src/lib/server/config.js'); Object.assign(config.rcon, { password: '' }); const job = await launch(action); const original = (await operationFor(job.deploymentId))!; const currentGeneration = action === 'rebuild' ? 2 : 1; const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconStatusResult).mockClear(); remote.state = 'unhealthy'; remote.players = 4; Object.assign(config.rcon, { password: 'changed-after-original-launch' }); await acknowledge(job.deploymentId); const restart = (await operationFor(job.deploymentId))!; const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); expect(restart).toMatchObject({ kind: 'instance.restart', state: 'pending', generation: currentGeneration, instanceId: original.instanceId, payload: { unhealthyRecovery: true } }); expect(getDeployment(job.deploymentId)!.capacity.pools).toEqual( expect.arrayContaining([ expect.objectContaining({ kind: 'startup', ownsSlot: true }), expect.objectContaining({ kind: 'restart', ownsSlot: true }) ]) ); expect(restart.id).not.toBe(original.id); expect(nativeInstance(job.name)?.generation).toBe(currentGeneration); expect( db() .prepare( 'SELECT recovery_step,generation FROM native_provisioning WHERE deployment_id=?' ) .get(job.deploymentId) ).toEqual({ recovery_step: 'restart', generation: currentGeneration }); expect(rconStatusResult).not.toHaveBeenCalled(); // Restarting the controller must preserve the current recovery command. closeDb(); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(restart.id); await acknowledge(job.deploymentId); const rebuild = (await operationFor(job.deploymentId))!; expect(rebuild).toMatchObject({ kind: 'instance.rebuild', state: 'pending', generation: currentGeneration, instanceId: original.instanceId, payload: { unhealthyRecovery: true, nextGeneration: currentGeneration + 1, files: original.payload.files, env: original.payload.env, rconPassword: original.payload.rconPassword, libraryId: original.payload.libraryId, image: original.payload.image } }); expect(rebuild.id).not.toBe(restart.id); expect(nativeInstance(job.name)?.generation).toBe(currentGeneration); expect(rconStatusResult).not.toHaveBeenCalled(); expect( db() .prepare( 'SELECT recovery_step,generation FROM native_provisioning WHERE deployment_id=?' ) .get(job.deploymentId) ).toEqual({ recovery_step: 'rebuild', generation: currentGeneration }); remote.state = 'running'; remote.players = 0; await acknowledge(job.deploymentId); expect(nativeInstance(job.name)?.generation).toBe(currentGeneration + 1); expect(await detail(job.deploymentId)).toMatchObject({ action, state: 'done' }); expect(getDeployment(job.deploymentId)!.progress?.recovery?.state).toBe('released'); expect( getDeployment(job.deploymentId)!.capacity.pools.every((pool) => !pool.ownsSlot) ).toBe(true); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id); expect(nativeInstance(job.name)?.generation).toBe(currentGeneration + 1); } ); it.each(['create', 'rebuild'] as const)( 'finishes %s after a successful recovery restart without another rebuild', async (action) => { const job = await launch(action); remote.state = 'unhealthy'; await acknowledge(job.deploymentId); const restart = (await operationFor(job.deploymentId))!; expect(restart.kind).toBe('instance.restart'); remote.state = 'running'; await acknowledge(job.deploymentId); await advance(); expect(await detail(job.deploymentId)).toMatchObject({ action, state: 'done' }); expect((await operationFor(job.deploymentId))!.id).toBe(restart.id); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(action === 'rebuild' ? 2 : 1); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'" ) .get() ).toEqual({ count: action === 'rebuild' ? 1 : 0 }); } ); it.each(['restart', 'rebuild'] as const)( 'retains an unknown recovery %s receipt without replaying or advancing recovery', async (step) => { const job = await launch('create'); remote.state = 'unhealthy'; await acknowledge(job.deploymentId); if (step === 'rebuild') await acknowledge(job.deploymentId); const operation = (await operationFor(job.deploymentId))!; expect(operation.kind).toBe(`instance.${step}`); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'unknown', error: 'Reply lost during recovery' }); const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb(); const { interruptDeployments } = await import('../../src/lib/server/provisioning/history.js'); interruptDeployments(); await advance(); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(operation.id); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind IN ('instance.create','instance.restart','instance.rebuild')" ) .get() ).toEqual({ count: step === 'restart' ? 2 : 3 }); expect((await detail(job.deploymentId)).state).not.toBe('failed'); remote.state = 'running'; await acknowledge(job.deploymentId); expect((await detail(job.deploymentId)).state).toBe('done'); } ); it.each(['starting', 'restarting', 'unknown'])( 'waits for %s container readiness without forcing recovery', async (state) => { const job = await launch('create'); const original = (await operationFor(job.deploymentId))!; remote.state = state; await acknowledge(job.deploymentId); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); expect(await detail(job.deploymentId)).toMatchObject({ state: 'awaiting-health', stage: `Waiting for container readiness: ${state}` }); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare('SELECT recovery_step FROM native_provisioning WHERE deployment_id=?') .get(job.deploymentId) ).toEqual({ recovery_step: null }); } ); it.each(['create', 'rebuild'] as const)( 'does not submit recovery if %s is cancelled during its health probe', async (action) => { const job = await launch(action); const original = (await operationFor(job.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, original.id, { state: 'completed' }); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const pending = advance(); await vi.waitFor(() => expect(release).toBeTypeOf('function')); expect(cancelProvisioningJob(job.deploymentId)).toBe(true); release({ healthy: false, state: 'unhealthy' } as never); await pending; await advance(); expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare("SELECT id FROM ec_host_operations WHERE kind='instance.restart'") .all() ).toEqual([]); } ); it.each(['create', 'rebuild'] as const)( 'reconciles a delivered recovery rebuild after its %s flow is cancelled', async (action) => { const job = await launch(action); remote.state = 'unhealthy'; await acknowledge(job.deploymentId); await acknowledge(job.deploymentId); const rebuild = (await operationFor(job.deploymentId))!; expect(rebuild.kind).toBe('instance.rebuild'); const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); pendingHostOperations(hostId); expect(cancelProvisioningJob(job.deploymentId)).toBe(true); reportHostOperation(hostId, rebuild.id, { state: 'completed' }); await advance(); await advance(); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(action === 'rebuild' ? 3 : 2); expect((await detail(job.deploymentId)).state).toBe('cancelled'); expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id); } ); it('fails clearly after an unhealthy recovery rebuild instead of looping', async () => { const job = await launch('create'); remote.state = 'unhealthy'; await acknowledge(job.deploymentId); await acknowledge(job.deploymentId); const rebuild = (await operationFor(job.deploymentId))!; expect(rebuild.kind).toBe('instance.rebuild'); await acknowledge(job.deploymentId); await advance(); expect(await detail(job.deploymentId)).toMatchObject({ state: 'failed', error: expect.stringMatching(/unhealthy.*restart.*rebuild/i) }); expect((await operationFor(job.deploymentId))!.id).toBe(rebuild.id); const { db } = await import('../../src/lib/server/db/index.js'); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind IN ('instance.create','instance.restart','instance.rebuild')" ) .get() ).toEqual({ count: 3 }); }); it.each(['create', 'rebuild'] as const)( 'holds unhealthy %s verification until the agent supports recovery', async (action) => { const job = await launch(action); const original = (await operationFor(job.deploymentId))!; const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(hostId, { ...inventory(), capabilities: [] }); remote.state = 'unhealthy'; await acknowledge(job.deploymentId); await advance(); expect((await operationFor(job.deploymentId))!.id).toBe(original.id); expect(await detail(job.deploymentId)).toMatchObject({ state: 'awaiting-health', stage: expect.stringMatching(/agent/i) }); heartbeat(hostId, inventory()); await advance(); expect(await operationFor(job.deploymentId)).toMatchObject({ kind: 'instance.restart', payload: { unhealthyRecovery: true } }); } ); it.each([false, true])( 'rebuilds an already unhealthy container without requiring RCON emptiness once supported (supported=%s)', async (supported) => { const created = await launch('create'); await acknowledge(created.deploymentId); const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); if (!supported) heartbeat(hostId, { ...inventory(), capabilities: [] }); const { queueNativeRebuild } = await import('../../src/lib/server/provisioning/native.js'); const rebuild = queueNativeRebuild(created.name, 'test'); if (!rebuild.ok) throw new Error(rebuild.message); const { rconStatusResult } = await import('../../src/lib/server/integrations/rcon.js'); vi.mocked(rconStatusResult).mockClear(); remote.state = 'unhealthy'; remote.players = 3; await advance(); if (!supported) { expect(await operationFor(rebuild.deploymentId)).toBeNull(); expect((await detail(rebuild.deploymentId)).stage).toMatch(/agent/i); heartbeat(hostId, inventory()); await advance(); } expect(await operationFor(rebuild.deploymentId)).toMatchObject({ kind: 'instance.rebuild', generation: 1, payload: { unhealthyRecovery: true, nextGeneration: 2 } }); expect(rconStatusResult).not.toHaveBeenCalled(); } ); }); describe('native provisioning cancellation and rollback', () => { it('cancels an unhealthy restart and prevents a late health response from reviving it', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const { POST } = await import('../../src/routes/api/provisioning/[id]/cancel/+server.js'); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const restart = requestLifecycle(job.name, 'restart'); if (!restart.ok) throw new Error(restart.error); await advance(); const command = (await operationFor(restart.deploymentId))!; reportHostOperation(hostId, command.id, { state: 'completed' }); let release!: (value: never) => void; vi.mocked(nativeRequest).mockImplementationOnce( () => new Promise((resolve) => { release = resolve; }) ); const pending = advance(); await vi.waitFor(() => expect(release).toBeTypeOf('function')); expect(POST({ params: { id: restart.deploymentId } }).status).toBe(200); release({ healthy: true, state: 'running' } as never); await pending; expect(await detail(restart.deploymentId)).toMatchObject({ state: 'cancelled' }); const replacement = requestLifecycle(job.name, 'restart'); if (!replacement.ok) throw new Error(replacement.error); await advance(); expect(await operationFor(replacement.deploymentId)).toMatchObject({ kind: 'instance.restart', state: 'pending' }); }); it.each([false, true])( 'withdraws an undelivered restart or waits for a delivered one before replacement (delivered=%s)', async (delivered) => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const first = requestLifecycle(job.name, 'restart'); if (!first.ok) throw new Error(first.error); await advance(); const command = (await operationFor(first.deploymentId))!; if (delivered) pendingHostOperations(hostId); expect(cancelProvisioningJob(first.deploymentId)).toBe(true); const next = requestLifecycle(job.name, 'restart'); if (!next.ok) throw new Error(next.error); await advance(); if (delivered) { expect(await operationFor(next.deploymentId)).toBeNull(); reportHostOperation(hostId, command.id, { state: 'completed' }); await advance(); } else { expect((await operationFor(first.deploymentId))?.state).toBe('failed'); expect(pendingHostOperations(hostId).some((op) => op.id === command.id)).toBe( false ); } expect(await operationFor(next.deploymentId)).not.toBeNull(); expect((await detail(first.deploymentId)).state).toBe('cancelled'); } ); it('rolls back a completed native activation even while its restart is stuck starting', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js'); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); const { script, version } = createScript({ releaseVersion: '1.0.0', name: 'Broken beta', kind: 'script', targetRelPath: 'beta.gsc', content: Buffer.from('broken'), scopeType: 'game', scopeValue: 't5' }); const betaId = Number( 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_scripts(beta_id,script_id,script_version_id) VALUES(?,?,?)') .run(betaId, script.id, version.id); db() .prepare( "INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')" ) .run(betaId, job.name); db() .prepare( 'INSERT INTO beta_server_scripts(beta_id,server,script_id,had_original) VALUES(?,?,?,0)' ) .run(betaId, job.name, script.id); queueFileChange({ sourceKind: 'beta', sourceId: String(betaId), server: job.name, kind: 'script', relPath: 'beta.gsc', content: Buffer.from('broken'), inventorySource: 'beta' }); const restart = requestRestart(job.name, { kind: 'beta', id: String(betaId) }); await advance(); const staging = db() .prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?') .get(restart.id) as { stage_operation_id: string }; reportHostOperation(hostId, staging.stage_operation_id, { state: 'completed' }); await advance(); const activation = (await operationFor(restart.id))!; const files = JSON.parse( ( db() .prepare('SELECT files_json FROM native_provisioning WHERE deployment_id=?') .get(restart.id) as { files_json: string } ).files_json ); remote.state = 'starting'; reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: files.map((f: { id: number; path: string }) => ({ ...f, contentBase64: Buffer.from('working').toString('base64') })) } }); // Rollback must consume the receipt itself; the health worker has not caught up. const rollback = await rollbackBeta(betaId); expect((await detail(restart.id)).state).toBe('cancelled'); const replacement = db() .prepare('SELECT provisioning_id FROM deployment_servers WHERE deployment_id=?') .get(rollback.deploymentId) as { provisioning_id: string }; expect(replacement.provisioning_id).not.toBe(restart.id); expect( db() .prepare( "SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind='campaign' AND source_id=?" ) .get(rollback.deploymentId) ).toEqual({ content: Buffer.from('working') }); await advance(); const stagedRollback = db() .prepare('SELECT stage_operation_id FROM native_provisioning WHERE deployment_id=?') .get(replacement.provisioning_id) as { stage_operation_id: string }; reportHostOperation(hostId, stagedRollback.stage_operation_id, { state: 'completed' }); await advance(); expect(await operationFor(replacement.provisioning_id)).toMatchObject({ state: 'pending' }); }); }); describe('partial rebuild failure reconciliation', () => { const originalError = 'Replacement game library could not be prepared'; const failure = (generation = 1) => ({ reconciled: true, originalError, rebuildFailure: { phase: 'disrupting', generation, nextGeneration: generation + 1, oldContainerMissing: true, replacementContainerMissing: true, replacementFilesRemoved: true } }); async function cancelledRebuild() { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const first = requestLifecycle(job.name, 'rebuild'); if (!first.ok) throw new Error(first.error); await advance(); const command = (await operationFor(first.deploymentId))!; const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); pendingHostOperations(hostId); reportHostOperation(hostId, command.id, { state: 'unknown', error: originalError }); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); expect(cancelProvisioningJob(first.deploymentId)).toBe(true); const replacement = requestLifecycle(job.name, 'rebuild'); if (!replacement.ok) throw new Error(replacement.error); return { job, first, command, replacement }; } it('records missing containers before releasing a queued rebuild without replaying the cancelled command', async () => { const { job, first, command, replacement } = await cancelledRebuild(); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const { getHostOperation, reportHostOperation, pendingHostOperations } = await import('../../src/lib/server/hosts/index.js'); const { nativeInstance, nativeRequest } = await import('../../src/lib/server/backends/native.js'); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { unsettledCancelledCommand } = await import('../../src/lib/server/provisioning/cancellation.js'); const ports = db() .prepare( 'SELECT host_id,instance_id,port,protocol FROM ec_host_ports ORDER BY protocol' ) .all(); await advance(); expect(await operationFor(replacement.deploymentId)).toBeNull(); expect(getDeployment(replacement.deploymentId)?.progress?.blocker).toMatchObject({ code: 'cancelled-command', href: `/provisioning/${first.deploymentId}`, label: 'View cancelled operation', message: expect.stringContaining(originalError) }); expect(getDeployment(replacement.deploymentId)?.progress?.blocker?.message).toContain( 'unknown' ); reportHostOperation(hostId, command.id, { state: 'failed', error: `Old container removed; replacement creation failed. ${originalError}`, result: failure() }); expect(unsettledCancelledCommand(job.name, replacement.deploymentId)).not.toBeNull(); expect( db() .prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?') .get(first.deploymentId) ).toEqual({ applied_at: null }); remote.state = 'missing'; vi.mocked(nativeRequest).mockImplementationOnce(async () => { expect( db() .prepare('SELECT container_state,last_players FROM servers WHERE name=?') .get(job.name) ).toEqual({ container_state: 'missing', last_players: null }); expect( db() .prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?') .get(first.deploymentId) ).toEqual({ applied_at: expect.any(String) }); expect(unsettledCancelledCommand(job.name, replacement.deploymentId)).toBeNull(); return { exists: false, healthy: false, state: 'missing' } as never; }); closeDb(); await advance(); const next = (await operationFor(replacement.deploymentId))!; expect(next).toMatchObject({ kind: 'instance.rebuild', generation: 1, instanceId: command.instanceId, payload: { nextGeneration: 2, env: { PLUTO_SERVER_KEY: 'saved-key' } } }); expect(next.id).not.toBe(command.id); expect(nativeInstance(job.name)).toMatchObject({ id: command.instanceId, generation: 1, retired_at: null }); expect(getServerKey(job.name)).toBe('saved-key'); expect( db() .prepare( 'SELECT host_id,instance_id,port,protocol FROM ec_host_ports ORDER BY protocol' ) .all() ).toEqual(ports); expect((await detail(first.deploymentId)).state).toBe('cancelled'); expect(getHostOperation(command.id)).toMatchObject({ state: 'failed', result: failure() }); expect(pendingHostOperations(hostId).some((operation) => operation.id === command.id)).toBe( false ); await advance(); expect((await operationFor(replacement.deploymentId))!.id).toBe(next.id); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'" ) .get() ).toEqual({ count: 2 }); remote.state = 'running'; await acknowledge(replacement.deploymentId); expect((await detail(replacement.deploymentId)).state).toBe('done'); expect(nativeInstance(job.name)?.generation).toBe(2); expect((await detail(first.deploymentId)).state).toBe('cancelled'); }); it.each(['disrupting', 'creating'])( 'keeps an ambiguous %s rebuild blocked even when inspections currently see no containers', async (phase) => { const { job, first, command, replacement } = await cancelledRebuild(); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, command.id, { state: 'unknown', error: originalError, result: { checkpoint: { phase }, state: 'missing', oldContainerMissing: true, replacementContainerMissing: true } }); remote.state = 'missing'; const { nativeRequest, nativeInstance } = await import('../../src/lib/server/backends/native.js'); vi.mocked(nativeRequest).mockClear(); const { closeDb, db } = await import('../../src/lib/server/db/index.js'); closeDb(); await advance(); await advance(); expect(await operationFor(replacement.deploymentId)).toBeNull(); expect(vi.mocked(nativeRequest)).not.toHaveBeenCalled(); expect((await operationFor(first.deploymentId))!.id).toBe(command.id); expect(nativeInstance(job.name)?.generation).toBe(1); expect( db() .prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?') .get(first.deploymentId) ).toEqual({ applied_at: null }); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'" ) .get() ).toEqual({ count: 1 }); } ); it.each([ 'creation started', 'replacement files remain', 'wrong target generation', 'command target mismatch' ])('does not release a replacement from invalid partial evidence: %s', async (evidence) => { const { first, command, replacement } = await cancelledRebuild(); const result = failure(); if (evidence === 'creation started') result.rebuildFailure.phase = 'creating'; if (evidence === 'replacement files remain') result.rebuildFailure.replacementFilesRemoved = false; if (evidence === 'wrong target generation') result.rebuildFailure.nextGeneration = 3; const { db } = await import('../../src/lib/server/db/index.js'); if (evidence === 'command target mismatch') db() .prepare( "UPDATE ec_host_operation_payloads SET payload=json_set(payload,'$.nextGeneration',3) WHERE operation_id=?" ) .run(command.id); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, command.id, { state: 'failed', error: originalError, result }); remote.state = 'missing'; await advance(); expect(await operationFor(replacement.deploymentId)).toBeNull(); expect( db() .prepare('SELECT applied_at FROM native_provisioning WHERE deployment_id=?') .get(first.deploymentId) ).toEqual({ applied_at: null }); expect( db() .prepare( "SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.rebuild'" ) .get() ).toEqual({ count: 1 }); }); it('records an uncancelled partial rebuild failure without claiming replacement generation or successful health', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const first = requestLifecycle(job.name, 'rebuild'); if (!first.ok) throw new Error(first.error); await advance(); const command = (await operationFor(first.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, command.id, { state: 'failed', result: failure(), error: `Old container removed; replacement creation failed. ${originalError}` }); await advance(); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); const { db } = await import('../../src/lib/server/db/index.js'); expect((await detail(first.deploymentId)).state).toBe('failed'); expect(nativeInstance(job.name)).toMatchObject({ generation: 1, retired_at: null }); expect( db() .prepare('SELECT container_state,last_players FROM servers WHERE name=?') .get(job.name) ).toEqual({ container_state: 'missing', last_players: null }); expect( db() .prepare( 'SELECT applied_at,verified_at FROM native_provisioning WHERE deployment_id=?' ) .get(first.deploymentId) ).toEqual({ applied_at: expect.any(String), verified_at: null }); expect( db() .prepare('SELECT COUNT(*) AS count FROM ec_host_ports WHERE instance_id=?') .get(command.instanceId) ).toEqual({ count: 2 }); const { getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); expect(getServerKey(job.name)).toBe('saved-key'); }); }); describe('cancelled native mutation bookkeeping', () => { it.each([null, 'before beta'])( 'keeps the original host receipt across beta revisions (original=%s)', async (original) => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); const { createScript } = await import('../../src/lib/server/fleet/scripts.js'); const { openBeta, reviseBeta, rollbackBeta } = await import('../../src/lib/server/fleet/beta.js'); const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js'); const { reportHostOperation, getHostOperation } = await import('../../src/lib/server/hosts/index.js'); const { script, version } = createScript({ releaseVersion: '1.0.0', name: 'Revised beta', kind: 'script', targetRelPath: 'revision.gsc', content: Buffer.from('revision one'), scopeType: 'game', scopeValue: 't5' }); const { betaId } = await openBeta({ scripts: [{ scriptId: script.id, scriptVersionId: version.id }], servers: [job.name], waitForHealth: true, waitForMatch: false }); async function activate(before: string | null) { const restart = requestRestart(job.name, { kind: 'beta', id: String(betaId) }); await advance(); const staged = db() .prepare( 'SELECT stage_operation_id FROM native_provisioning WHERE deployment_id = ?' ) .get(restart.id) as { stage_operation_id: string }; const files = getHostOperation(staged.stage_operation_id)!.payload.files as Array<{ id: number; path: string; }>; reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed' }); await advance(); // A dispatched activation must settle before revision submission. await expect( reviseBeta(betaId, 1, [ { scriptId: script.id, releaseVersion: '1.0.1', content: Buffer.from('premature update') } ]) ).rejects.toThrow(); const activation = (await operationFor(restart.id))!; expect(activation.kind).toBe('instance.rebuild'); expect(activation.payload).toMatchObject({ stageId: restart.id, nextGeneration: activation.generation! + 1 }); expect(activation.payload).not.toHaveProperty('unhealthyRecovery'); reportHostOperation(hostId, activation.id, { state: 'completed', result: { before: files.map((file) => ({ ...file, contentBase64: before === null ? null : Buffer.from(before).toString('base64') })) } }); await advance(); expect((await detail(restart.id)).state).toBe('done'); } await activate(original); await reviseBeta(betaId, 1, [ { scriptId: script.id, releaseVersion: '1.0.1', content: Buffer.from('revision two') } ]); await activate('revision one'); const rollback = await rollbackBeta(betaId); expect( db() .prepare( "SELECT (SELECT content FROM file_blobs WHERE sha256=content_hash) AS content FROM staged_file_changes WHERE source_kind = 'campaign' AND source_id = ?" ) .get(rollback.deploymentId) ).toEqual({ content: original === null ? null : Buffer.from(original) }); } ); it('keeps explicit cleanup available after cancelling an unsubmitted creation', async () => { const job = await queue(); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); const { cancelCreation, canCancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); expect(cancelProvisioningJob(job.deploymentId)).toBe(true); expect(canCancelCreation(job.deploymentId)).toBe(true); const cleanup = cancelCreation(job.deploymentId)!; expect((await detail(cleanup)).state).toBe('done'); }); it.each(['rebuild', 'destroy'] as const)( 'reconciles a delivered %s receipt without resuming the cancelled job', async (action) => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); const { serverKeyPool, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); const { pendingHostOperations, reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); const result = action === 'rebuild' ? requestLifecycle(job.name, 'rebuild') : destroyServer(job.name); if (!result.ok || !result.deploymentId) throw new Error('Job was not queued'); const id = result.deploymentId; await advance(); const operation = (await operationFor(id))!; pendingHostOperations(hostId); expect(cancelProvisioningJob(id)).toBe(true); expect(serverKeyPool('t5').available).toBe(0); const replacement = action === 'rebuild' ? requestLifecycle(job.name, 'restart') : null; if (replacement && !replacement.ok) throw new Error(replacement.error); await advance(); reportHostOperation(hostId, operation.id, { state: 'completed' }); await advance(); await advance(); if (replacement?.ok) expect(await operationFor(replacement.deploymentId)).toMatchObject({ kind: 'instance.restart', generation: 2 }); expect((await detail(id)).state).toBe('cancelled'); expect(serverKeyPool('t5').available).toBe(action === 'destroy' ? 1 : 0); expect(getServerKey(job.name)).toBe(action === 'destroy' ? undefined : 'saved-key'); if (action === 'rebuild') expect( db() .prepare( 'SELECT generation,retired_at FROM native_instances WHERE server=?' ) .get(job.name) ).toEqual({ generation: 2, retired_at: null }); else { expect(db().prepare('SELECT * FROM ec_host_ports').all()).toEqual([]); expect( db().prepare('SELECT * FROM servers WHERE name=?').get(job.name) ).toBeUndefined(); } } ); it.each([false, true])( 'cancels a host migration without restarting it or leaking destination reservations (delivered=%s)', async (delivered) => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const hosts = await import('../../src/lib/server/hosts/index.js'); const { serverKeyPool, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { getLibraryVersion, updateHostLibraryCaches } = await import('../../src/lib/server/game-files/index.js'); const { db } = await import('../../src/lib/server/db/index.js'); const { processProvisioning } = await import('../../src/lib/server/provisioning/worker.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); const destination = hosts.createHost({ name: 'Destination', portMin: 29000, portMax: 29005 }); hosts.enrollHost(hosts.createEnrollment(destination.id).token, inventory()); updateHostLibraryCaches(destination.id, [ { libraryVersionId: libraryId, status: 'ready', manifestSha256: getLibraryVersion(libraryId)!.manifestSha256 } ]); hosts.updateHost(hostId, { mode: 'draining' }); await processProvisioning(); const migration = db() .prepare('SELECT deployment_id,remove_operation_id FROM ec_host_migrations') .get() as { deployment_id: string; remove_operation_id: string }; expect(migration.remove_operation_id).toBeTruthy(); if (delivered) hosts.pendingHostOperations(hostId); expect(cancelProvisioningJob(migration.deployment_id)).toBe(true); expect(serverKeyPool('t5').available).toBe(0); if (delivered) hosts.reportHostOperation(hostId, migration.remove_operation_id, { state: 'completed' }); await processProvisioning(); await processProvisioning(); expect((await detail(migration.deployment_id)).state).toBe('cancelled'); expect( db().prepare('SELECT * FROM ec_host_ports WHERE host_id=?').all(destination.id) ).toEqual([]); expect(db().prepare('SELECT * FROM ec_host_migrations').all()).toHaveLength(1); expect( db().prepare('SELECT * FROM ec_host_ports WHERE host_id=?').all(hostId) ).toHaveLength(delivered ? 0 : 2); expect(serverKeyPool('t5').available).toBe(delivered ? 1 : 0); expect(getServerKey(job.name)).toBe(delivered ? undefined : 'saved-key'); } ); }); describe('additional concurrency admission', () => { it.each(['createConcurrencyPerHost', 'lifecycleConcurrency'] as const)( 'holds simultaneous startups with %s until recovery completes', async (key) => { const first = await queue(); const { fleetSettings, saveFleetSettings } = await import('../../src/lib/server/settings.js'); saveFleetSettings({ ...fleetSettings(), createConcurrency: 5, [key]: 1 }); const { queueCreateServer } = await import('../../src/lib/server/provisioning/instances.js'); const second = queueCreateServer({ game: 't5', gameMode: 'zombie', mapSlug: 'theater', hostId, libraryId, image, plutoKey: 'second-key' }); if (!second.ok) throw new Error(second.message); await advance(); const operations = [ await operationFor(first.deploymentId), await operationFor(second.deploymentId) ]; expect(operations.filter(Boolean)).toHaveLength(1); expect((await detail(second.deploymentId)).stage).toContain('capacity'); saveFleetSettings({ ...fleetSettings(), [key]: 0 }); await advance(); expect(await operationFor(second.deploymentId)).not.toBeNull(); } ); }); describe('match history on server stop', () => { async function setupStop() { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { db } = await import('../../src/lib/server/db/index.js'); const handle = db(); const insert = handle.prepare(`INSERT INTO leaderboard_matches (server, game, map, map_key, map_name, started_at, last_received_at, status, ended_at, server_deleted_at, highest_round, last_event, last_fingerprint) VALUES (?, 't5', 'zombie_theater', 't5:zombie_theater', 'Kino Der Toten', '2026-10-01T00:00:00Z', '2026-10-01T01:00:00Z', ?, ?, ?, 20, 'heartbeat', 'test')`); insert.run(job.name, 'active', null, null); insert.run(job.name, 'completed', '2026-10-01T01:00:00Z', null); insert.run(job.name, 'interrupted', '2026-10-01T01:00:00Z', null); insert.run(job.name, 'active', null, '2026-10-01T01:00:00Z'); insert.run('another-server', 'active', null, null); const matches = () => handle.prepare('SELECT * FROM leaderboard_matches ORDER BY id').all(); const before = matches(); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const stopped = requestLifecycle(job.name, 'stop'); if (!stopped.ok) throw new Error(stopped.error); await advance(); expect(matches()).toEqual(before); return { stopped, matches, before }; } it.each(['stopped', 'exited', 'created', 'dead'])( 'interrupts only the current open match after verifying the container is %s', async (state) => { const { stopped, matches, before } = await setupStop(); // A successful command receipt alone does not prove the container stopped. await acknowledge(stopped.deploymentId); expect(matches()).toEqual(before); remote.state = state; const verifiedAfter = Date.now(); await advance(); expect((await detail(stopped.deploymentId)).state).toBe('done'); const after = matches(); expect(after[0]).toEqual({ ...(before[0] as object), status: 'interrupted', ended_at: expect.any(String) }); const endedAt = Date.parse((after[0] as { ended_at: string }).ended_at); expect(endedAt).toBeGreaterThanOrEqual(verifiedAfter); expect(endedAt).toBeLessThanOrEqual(Date.now()); expect(after.slice(1)).toEqual(before.slice(1)); await advance(); expect(matches()).toEqual(after); } ); it.each(['failed', 'unknown'] as const)( 'keeps the match open when the stop command is %s', async (state) => { const { stopped, matches, before } = await setupStop(); const operation = (await operationFor(stopped.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state, error: 'Stop not confirmed' }); await advance(); expect(matches()).toEqual(before); } ); }); describe('game key pool launch lifecycle', () => { it('reserves across maps atomically, rejects duplicate credentials, and reuses a cancelled launch key', async () => { await prepareLibrary(); const { saveServerKey, serverKeyPool, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { queueCreateServer, destroyServer, previewCreation } = await import('../../src/lib/server/provisioning/instances.js'); saveServerKey('t5', 'shared-t5-key', 'Any T5 map'); saveServerKey('t6', 'shared-t6-key'); const input = { game: 't5' as const, gameMode: 'zombie' as const, mapSlug: 'moon', hostId, libraryId, image }; expect(previewCreation(input).hasSavedKey).toBe(true); const first = queueCreateServer(input); if (!first.ok) throw new Error(first.message); expect(first.name).toBe('t5_zombie_moon_1'); expect(getServerKey(first.name)).toBe('shared-t5-key'); expect(serverKeyPool('t5')).toEqual({ total: 1, available: 0 }); expect(serverKeyPool('t6')).toEqual({ total: 1, available: 1 }); expect(previewCreation({ ...input, mapSlug: 'theater' }).hasSavedKey).toBe(false); expect(queueCreateServer({ ...input, mapSlug: 'theater' }).ok).toBe(false); expect( queueCreateServer({ ...input, mapSlug: 'theater', plutoKey: 'shared-t5-key' }).ok ).toBe(false); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); closeDb(); expect(serverKeyPool('t5').available).toBe(0); expect(db().prepare('SELECT * FROM server_creation_jobs').all()).toHaveLength(1); expect(destroyServer(first.name).ok).toBe(true); expect(serverKeyPool('t5').available).toBe(1); const second = queueCreateServer({ ...input, mapSlug: 'theater' }); if (!second.ok) throw new Error(second.message); expect(getServerKey(second.name)).toBe('shared-t5-key'); expect(getServerKey(first.name)).toBeUndefined(); }); it('keeps a key through stop and rebuild, then releases it only after confirmed removal', async () => { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { serverKeyPool, getServerKey } = await import('../../src/lib/server/fleet/server-keys.js'); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const stopped = requestLifecycle(job.name, 'stop'); if (!stopped.ok) throw new Error(stopped.error); await advance(); remote.state = 'stopped'; await acknowledge(stopped.deploymentId); expect(serverKeyPool('t5').available).toBe(0); const rebuilt = requestLifecycle(job.name, 'rebuild'); if (!rebuilt.ok) throw new Error(rebuilt.error); await advance(); remote.state = 'running'; await acknowledge(rebuilt.deploymentId); expect(getServerKey(job.name)).toBe('saved-key'); expect(serverKeyPool('t5').available).toBe(0); const removal = destroyServer(job.name); if (!removal.ok || !removal.deploymentId) throw new Error('Expected removal'); await advance(); expect(serverKeyPool('t5').available).toBe(0); const operation = (await operationFor(removal.deploymentId))!; const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'unknown', error: 'Receipt lost' }); await advance(); expect(serverKeyPool('t5').available).toBe(0); await acknowledge(removal.deploymentId); expect(serverKeyPool('t5')).toEqual({ total: 1, available: 1 }); expect(getServerKey(job.name)).toBeUndefined(); }); it('keeps the reservation for a submitted cancellation until the host confirms cleanup', async () => { const job = await queue(); await advance(); const { serverKeyPool } = await import('../../src/lib/server/fleet/server-keys.js'); const { cancelCreation } = await import('../../src/lib/server/provisioning/cancel-creation.js'); const cleanup = cancelCreation(job.deploymentId)!; expect(cleanup).toEqual(expect.any(String)); expect(serverKeyPool('t5').available).toBe(0); await acknowledge(cleanup); expect(serverKeyPool('t5')).toEqual({ total: 1, available: 1 }); }); }); describe('completed rebuild generation receipts (#155)', () => { async function rebuild() { const job = await queue(); await advance(); await acknowledge(job.deploymentId); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const requested = requestLifecycle(job.name, 'rebuild'); if (!requested.ok) throw new Error(requested.error); await advance(); return { job, id: requested.deploymentId, operation: (await operationFor(requested.deploymentId))! }; } async function inventoryGeneration( instanceId: string, generation: number, observedAt = new Date().toISOString(), targetHost = hostId ) { const { heartbeat } = await import('../../src/lib/server/hosts/index.js'); heartbeat(targetHost, { ...inventory(), instanceStates: [ { instanceId, generation, state: 'running', healthy: true, observedAt } ] }); } it('repairs an older job binding from the actual command before verification, including duplicate receipts and restart', async () => { const { job, id } = await rebuild(); await acknowledge(id); const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js'); const next = requestLifecycle(job.name, 'rebuild'); if (!next.ok) throw new Error(next.error); await advance(); const operation = (await operationFor(next.deploymentId))!; expect(operation.generation).toBe(2); const { db, closeDb } = await import('../../src/lib/server/db/index.js'); // Historical job snapshots could stay on generation 1 while the submitted // command correctly targeted 2 -> 3. The old completion path kept 2. db() .prepare('UPDATE native_provisioning SET generation=1 WHERE deployment_id=?') .run(next.deploymentId); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { instanceId: operation.instanceId, generation: 3, healthy: true } }); await inventoryGeneration(operation.instanceId!, 3); closeDb(); const { nativeRequest, nativeInstance } = await import('../../src/lib/server/backends/native.js'); vi.mocked(nativeRequest).mockClear(); remote.state = 'starting'; await advance(); expect(nativeInstance(job.name)?.generation).toBe(3); expect(nativeRequest).toHaveBeenCalledWith( expect.objectContaining({ id: operation.instanceId, generation: 3 }), 'instance.inspect' ); expect( db() .prepare( 'SELECT generation,applied_at FROM native_provisioning WHERE deployment_id=?' ) .get(next.deploymentId) ).toEqual({ generation: 2, applied_at: expect.any(String) }); const { reconcileNativeProvisioningReceipt } = await import('../../src/lib/server/provisioning/native.js'); const before = db() .prepare('SELECT generation_reconciled_at FROM native_instances WHERE id=?') .get(operation.instanceId); reconcileNativeProvisioningReceipt(next.deploymentId); closeDb(); reconcileNativeProvisioningReceipt(next.deploymentId); expect(nativeInstance(job.name)?.generation).toBe(3); expect( db() .prepare('SELECT generation_reconciled_at FROM native_instances WHERE id=?') .get(operation.instanceId) ).toEqual(before); expect((await detail(next.deploymentId)).state).toBe('awaiting-health'); for (const observation of await observations(job.name)) expect(observation.health).not.toBe('online'); const { latestGameSubmission, latestGameSubmissions } = await import('../../src/lib/server/fleet/lifecycle-evidence.js'); expect(latestGameSubmission(job.name)).toBe( (before as { generation_reconciled_at: string }).generation_reconciled_at ); expect(latestGameSubmissions().get(job.name)).toBe(latestGameSubmission(job.name)); remote.state = 'running'; await advance(); expect((await detail(next.deploymentId)).state).toBe('done'); const restart = requestLifecycle(job.name, 'restart'); if (!restart.ok) throw new Error(restart.error); await advance(); expect(await operationFor(restart.deploymentId)).toMatchObject({ generation: 3 }); }); it.each(['before', 'after'] as const)( 'reconciles delayed completion reported %s cancellation across restart', async (when) => { const { job, id, operation } = await rebuild(); const { reportHostOperation, pendingHostOperations } = await import('../../src/lib/server/hosts/index.js'); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); const { closeDb } = await import('../../src/lib/server/db/index.js'); pendingHostOperations(hostId); if (when === 'before') reportHostOperation(hostId, operation.id, { state: 'completed' }); expect(cancelProvisioningJob(id)).toBe(true); closeDb(); if (when === 'after') reportHostOperation(hostId, operation.id, { state: 'completed' }); await advance(); await advance(); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(2); expect((await detail(id)).state).toBe('cancelled'); } ); it('recovers an already-applied cancelled mismatch through the API and targets replacement logs, health and removal', async () => { const { job, id, operation } = await rebuild(); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { instanceId: operation.instanceId, generation: 2 } }); const { cancelProvisioningJob } = await import('../../src/lib/server/provisioning/cancellation.js'); expect(cancelProvisioningJob(id)).toBe(true); const { db } = await import('../../src/lib/server/db/index.js'); db() .prepare('UPDATE native_provisioning SET applied_at=? WHERE deployment_id=?') .run(operation.updatedAt, id); await inventoryGeneration(operation.instanceId!, 2); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); expect(getDeployment(id)?.generationRecovery).toMatchObject({ expectedGeneration: 1, observedGeneration: 2, available: true }); const { POST } = await import('../../src/routes/api/provisioning/[id]/reconcile-generation/+server.js'); expect(await POST({ params: { id } }).json()).toEqual({ ok: true, generation: 2 }); expect(POST({ params: { id } }).status).toBe(200); expect((await detail(id)).state).toBe('cancelled'); expect(getDeployment(id)?.generationRecovery).toBeNull(); const backend = await import('../../src/lib/server/backends/native.js'); vi.mocked(backend.nativeRequest).mockRestore(); const hosts = await import('../../src/lib/server/hosts/index.js'); vi.spyOn(hosts, 'waitHostOperationStatus').mockImplementation(async (id) => hosts.reportHostOperationStatus(hostId, id, { state: 'completed', result: { healthy: true, state: 'running' } }) ); await backend.nativeRequest(backend.nativeInstance(job.name)!, 'logs.read', { file: 'console.log' }); expect(await backend.nativeHealth(job.name)).toMatchObject({ ok: true }); expect( db() .prepare( "SELECT generation FROM ec_host_operations WHERE kind IN ('logs.read','instance.inspect')" ) .all() .every((row) => (row as { generation: number }).generation === 2) ).toBe(true); const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js'); const removal = destroyServer(job.name); if (!removal.ok || !removal.deploymentId) throw new Error('Removal not queued'); await advance(); expect(await operationFor(removal.deploymentId)).toMatchObject({ generation: 2, kind: 'instance.remove' }); }); it.each([ 'host', 'instance', 'result-instance', 'result-generation', 'payload', 'stale-inventory', 'missing-inventory', 'inventory-instance' ] as const)('rejects recovery evidence with %s mismatch', async (mismatch) => { const { job, id, operation } = await rebuild(); const { reportHostOperation, createHost } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed', result: { instanceId: mismatch === 'result-instance' ? 'another-instance' : operation.instanceId, generation: mismatch === 'result-generation' ? 3 : 2 } }); const { db } = await import('../../src/lib/server/db/index.js'); if (mismatch === 'host') db() .prepare('UPDATE ec_host_operations SET host_id=? WHERE id=?') .run(createHost({ name: 'Other host' }).id, operation.id); if (mismatch === 'instance') db() .prepare('UPDATE ec_host_operations SET instance_id=? WHERE id=?') .run('another-instance', operation.id); if (mismatch === 'payload') db() .prepare( "UPDATE ec_host_operation_payloads SET payload=json_set(payload,'$.nextGeneration',3) WHERE operation_id=?" ) .run(operation.id); if (mismatch !== 'missing-inventory') await inventoryGeneration( mismatch === 'inventory-instance' ? 'another-instance' : operation.instanceId!, 2, mismatch === 'stale-inventory' ? '2000-01-01T00:00:00Z' : new Date().toISOString() ); const { POST } = await import('../../src/routes/api/provisioning/[id]/reconcile-generation/+server.js'); expect(POST({ params: { id } }).status).toBe(409); const { nativeInstance } = await import('../../src/lib/server/backends/native.js'); expect(nativeInstance(job.name)?.generation).toBe(1); }); it('pauses after an agent stale-generation rejection even when inventory is unavailable', async () => { const { id, operation } = await rebuild(); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed' }); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); vi.mocked(nativeRequest).mockClear(); vi.mocked(nativeRequest).mockRejectedValueOnce( new Error('Instance generation has been superseded or removed') ); await advance(); expect((await detail(id)).state).toBe('interrupted'); expect((await detail(id)).error).toContain('controller expects 2, host reports unknown'); await advance(); expect(nativeRequest).toHaveBeenCalledTimes(1); }); it('stops verification retries on a confirmed generation mismatch and keeps expected/observed evidence', async () => { const { id, operation } = await rebuild(); const { reportHostOperation } = await import('../../src/lib/server/hosts/index.js'); reportHostOperation(hostId, operation.id, { state: 'completed' }); await inventoryGeneration(operation.instanceId!, 3); const { nativeRequest } = await import('../../src/lib/server/backends/native.js'); vi.mocked(nativeRequest).mockClear(); await advance(); const { getDeployment } = await import('../../src/lib/server/provisioning/history.js'); const before = getDeployment(id)!; expect(before.deployment.state).toBe('interrupted'); expect(before.progress?.blocker).toMatchObject({ code: 'generation-recovery', automatic: false, message: expect.stringContaining( 'controller expects 1, completed rebuild requests 2, host reports 3' ) }); await advance(); await advance(); expect(nativeRequest).not.toHaveBeenCalled(); expect(getDeployment(id)?.events).toEqual(before.events); }); });