163 lines
7.9 KiB
TypeScript
163 lines
7.9 KiB
TypeScript
import Database from 'better-sqlite3';
|
|
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
|
|
import schema from '../../src/lib/server/db/schema.sql?raw';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
let fixture: Fixture;
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
fixture = await newFixture([]);
|
|
});
|
|
afterEach(async () => {
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
it('migrates idle, occupied and duplicate legacy keys without releasing unresolved assignments', async () => {
|
|
const previous = new Database(fixture.dbPath);
|
|
previous.exec(schema);
|
|
previous.exec(`DROP TABLE server_keys;
|
|
CREATE TABLE server_keys(server_id TEXT PRIMARY KEY,pluto_key TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT (datetime('now')));`);
|
|
const { migrateNativeInstances } = await import('../../src/lib/server/backends/schema.js');
|
|
migrateNativeInstances(previous);
|
|
const cases = [
|
|
['t5_zombie_theater_1', 'shared-occupied'],
|
|
['t5_zombie_pentagon_1', 'shared-occupied'],
|
|
['t5_zombie_cosmodrome_1', 'native-only'],
|
|
['t5_zombie_temple_1', 'queued-key'],
|
|
['t5_zombie_sumpf_1', 'failed-key'],
|
|
['t5_zombie_moon_1', 'idle-key'],
|
|
['t5_zombie_moon_2', 'idle-key'],
|
|
['t6_zombie_town_1', 't6-key']
|
|
];
|
|
for (const [server, credential] of cases)
|
|
previous
|
|
.prepare('INSERT INTO server_keys(server_id,pluto_key,updated_at) VALUES(?,?,?)')
|
|
.run(server, credential, '2026-01-01');
|
|
previous.exec(`INSERT INTO servers(name,game,desired_state) VALUES('t5_zombie_theater_1','t5','running'),('t5_zombie_pentagon_1','t5','stopped');
|
|
INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
|
VALUES('native','t5_zombie_cosmodrome_1','host','library','image',28960,'2026-01-01');
|
|
INSERT INTO server_deployments(id,server,action,state,stage,started_at,triggered_by) VALUES
|
|
('queued','t5_zombie_temple_1','create','queued','Queued','2026-01-01','fixture'),
|
|
('failed','t5_zombie_sumpf_1','create','failed','Placement failed','2026-01-01','fixture');`);
|
|
previous.close();
|
|
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
const pool = await import('../../src/lib/server/fleet/server-keys.js');
|
|
for (let pass = 0; pass < 2; pass++) {
|
|
expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 });
|
|
expect(pool.serverKeyPool('t6')).toEqual({ total: 1, available: 1 });
|
|
expect(pool.getServerKey('t5_zombie_theater_1')).toBe('shared-occupied');
|
|
expect(pool.getServerKey('t5_zombie_pentagon_1')).toBe('shared-occupied');
|
|
expect(pool.getServerKey('t5_zombie_cosmodrome_1')).toBe('native-only');
|
|
expect(pool.getServerKey('t5_zombie_temple_1')).toBe('queued-key');
|
|
expect(pool.getServerKey('t5_zombie_sumpf_1')).toBe('failed-key');
|
|
expect(pool.getServerKey('t5_zombie_moon_1')).toBeUndefined();
|
|
expect(db().pragma('foreign_key_check')).toEqual([]);
|
|
closeDb();
|
|
}
|
|
const duplicate = pool.listServerKeys().find((key) => key.assigned_servers.length === 2)!;
|
|
expect(duplicate.assigned_servers).toEqual(['t5_zombie_pentagon_1', 't5_zombie_theater_1']);
|
|
expect(duplicate.updated_at).toBe('2026-01-01');
|
|
expect(pool.reserveServerKey('t5_zombie_theater_2', 't5')).toBe('idle-key');
|
|
pool.releaseServerKey('t5_zombie_theater_1');
|
|
expect(pool.availableServerKeyCount('t5')).toBe(0);
|
|
expect(() => pool.removeServerKey(duplicate.id)).toThrow('reserved');
|
|
pool.releaseServerKey('t5_zombie_pentagon_1');
|
|
expect(pool.reserveServerKey('t5_zombie_theater_3', 't5')).toBe('shared-occupied');
|
|
});
|
|
|
|
it('recovers actual launch credentials and holds both sides of an unresolved legacy rebuild', async () => {
|
|
const previous = new Database(fixture.dbPath);
|
|
previous.exec(schema);
|
|
previous.exec(`DROP TABLE server_keys;
|
|
CREATE TABLE server_keys(server_id TEXT PRIMARY KEY,pluto_key TEXT NOT NULL,updated_at TEXT NOT NULL DEFAULT (datetime('now')));`);
|
|
const { migrateNativeInstances } = await import('../../src/lib/server/backends/schema.js');
|
|
const { ensureHostSchema } = await import('../../src/lib/server/hosts/schema.js');
|
|
migrateNativeInstances(previous);
|
|
ensureHostSchema(previous);
|
|
previous.exec(`INSERT INTO ec_hosts(id,name,created_at) VALUES('host','Fixture host','2026-01-01');
|
|
INSERT INTO servers(name,game) VALUES('t5_zombie_theater_1','t5'),('t5_zombie_pentagon_1','t5');
|
|
INSERT INTO server_keys(server_id,pluto_key) VALUES('t5_zombie_theater_1','edited-saved-key'),
|
|
('t5_zombie_pentagon_1','original-key'),('t5_zombie_moon_1','original-key');
|
|
INSERT INTO native_instances(id,server,host_id,generation,library_id,image,port,created_at) VALUES
|
|
('kino','t5_zombie_theater_1','host',2,'library','image',28960,'2026-01-01'),
|
|
('five','t5_zombie_pentagon_1','host',1,'library','image',28961,'2026-01-01'),
|
|
('queued','t5_zombie_cosmodrome_1','host',1,'library','image',28962,'2026-01-01');`);
|
|
function operation(
|
|
id: string,
|
|
instance: string,
|
|
generation: number,
|
|
kind: string,
|
|
state: string,
|
|
key: string,
|
|
staged = false,
|
|
nextGeneration?: number
|
|
) {
|
|
const payload = JSON.stringify({
|
|
game: 't5',
|
|
env: { PLUTO_SERVER_KEY: key },
|
|
nextGeneration
|
|
});
|
|
previous
|
|
.prepare(
|
|
`INSERT INTO ec_host_operations(id,host_id,instance_id,generation,kind,state,payload,created_at,updated_at)
|
|
VALUES(?,'host',?,?,?,?,?,'2026-01-01','2026-01-01')`
|
|
)
|
|
.run(id, instance, generation, kind, state, staged ? '{}' : payload);
|
|
if (staged)
|
|
previous
|
|
.prepare('INSERT INTO ec_host_operation_payloads(operation_id,payload) VALUES(?,?)')
|
|
.run(id, payload);
|
|
}
|
|
operation('obsolete-kino', 'kino', 1, 'instance.create', 'completed', 'obsolete-key');
|
|
operation('actual-kino', 'kino', 1, 'instance.rebuild', 'completed', 'actual-key', true, 2);
|
|
operation('original-five', 'five', 1, 'instance.create', 'completed', 'original-key');
|
|
operation('unknown-five', 'five', 1, 'instance.rebuild', 'unknown', 'pending-key', true, 2);
|
|
operation(
|
|
'unknown-creation',
|
|
'queued',
|
|
1,
|
|
'instance.create',
|
|
'unknown',
|
|
'unknown-creation-key'
|
|
);
|
|
operation(
|
|
'failed-new-key',
|
|
'kino',
|
|
2,
|
|
'instance.rebuild',
|
|
'failed',
|
|
'never-applied-key',
|
|
true,
|
|
3
|
|
);
|
|
previous.close();
|
|
|
|
const pool = await import('../../src/lib/server/fleet/server-keys.js');
|
|
expect(pool.getServerKey('t5_zombie_theater_1')).toBe('actual-key');
|
|
expect(pool.getServerKey('t5_zombie_pentagon_1')).toBe('pending-key');
|
|
expect(pool.getServerKey('t5_zombie_cosmodrome_1')).toBe('unknown-creation-key');
|
|
expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 });
|
|
expect(pool.serverKeyPool('t5', 't5_zombie_pentagon_1')).toEqual({ total: 5, available: 2 });
|
|
expect(() => pool.reserveServerKey('t5_zombie_moon_2', 't5', 'original-key')).toThrow(
|
|
'already reserved'
|
|
);
|
|
const { db, closeDb } = await import('../../src/lib/server/db/index.js');
|
|
expect(
|
|
db()
|
|
.prepare(
|
|
`SELECT k.pluto_key,l.is_current FROM server_key_leases l JOIN server_keys k ON k.id=l.key_id
|
|
WHERE l.server_id='t5_zombie_pentagon_1' ORDER BY l.is_current`
|
|
)
|
|
.all()
|
|
).toEqual([
|
|
{ pluto_key: 'original-key', is_current: 0 },
|
|
{ pluto_key: 'pending-key', is_current: 1 }
|
|
]);
|
|
expect(db().pragma('foreign_key_check')).toEqual([]);
|
|
closeDb();
|
|
expect(pool.serverKeyPool('t5')).toEqual({ total: 5, available: 1 });
|
|
pool.releaseServerKey('t5_zombie_pentagon_1');
|
|
expect(pool.availableServerKeyCount('t5')).toBe(3);
|
|
});
|