Migrate instances when draining and verify hosts before enabling
This commit is contained in:
@@ -543,23 +543,138 @@ describe('native instance provisioning', () => {
|
||||
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('blocks new admission on draining hosts and retires existing instances only when empty', async () => {
|
||||
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 { updateHost } = await import('../../src/lib/server/hosts/index.js'); updateHost(hostId, { mode: 'draining' });
|
||||
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/);
|
||||
remote.players = 2; await advance();
|
||||
const { db } = await import('../../src/lib/server/db/index.js');
|
||||
const retirement = db().prepare("SELECT id FROM server_deployments WHERE action='destroy' AND server=?").get(job.name) as { id: string };
|
||||
expect(retirement).toBeTruthy(); expect(await operationFor(retirement.id)).toBeNull();
|
||||
remote.players = 0; await advance();
|
||||
const remove = await acknowledge(retirement.id);
|
||||
expect(remove.kind).toBe('instance.remove');
|
||||
expect(db().prepare('SELECT * FROM servers WHERE name=?').get(job.name)).toBeUndefined();
|
||||
expect(db().prepare('SELECT * FROM ec_host_ports WHERE instance_id=?').all(remove.instanceId)).toEqual([]);
|
||||
const { getLibraryVersion } = await import('../../src/lib/server/game-files/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('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');
|
||||
|
||||
Reference in New Issue
Block a user