Introduce an outbound Python host agent, Debian installer, durable remote operations, immutable game-file libraries, host-aware deployments and ports, and occupancy-safe campaigns and draining. Native hosts do not require Komodo. Add administrator, operator and viewer accounts, protected management routes, secure sessions, password recovery, audit history and separate telemetry tokens. Document first-admin setup, reporter migration and live-host acceptance checks. Validation: 1,343 TypeScript tests and 39 agent tests passed, along with type checking, the production build, compiled SSR/API smoke tests and administrator recovery. Real Debian/Docker T5/T6 acceptance remains unrun.
122 lines
8.8 KiB
TypeScript
122 lines
8.8 KiB
TypeScript
import { randomUUID } from 'node:crypto';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
|
|
vi.mock('../../src/lib/server/integrations/rcon.js', () => ({
|
|
rconStatusResult: vi.fn(async () => ({ ok: true, endpoint: { host: 'agent', port: 28960 }, rawReply: '', status: { players: 0, map: 'zombie_theater', gametype: 'zom', roster: [] } })),
|
|
rconGetPlayerCount: vi.fn(async () => 0), rconPlutoniumVersion: vi.fn(async () => ({ revision: 5354, error: null }))
|
|
}));
|
|
vi.mock('../../src/lib/server/pipeline/worker.js', () => ({ kick: vi.fn(async () => {}) }));
|
|
|
|
let fixture: Fixture; let hostId: string; let instanceId: string;
|
|
const server = 't5_zombie_theater_1';
|
|
const healthy = { exists: true, healthy: true, state: 'running' };
|
|
beforeEach(async () => {
|
|
vi.resetModules(); vi.clearAllMocks(); fixture = await newFixture([]);
|
|
vi.stubEnv('KOMODO_ADDRESS', ''); vi.stubEnv('KOMODO_API_KEY', ''); vi.stubEnv('KOMODO_API_SECRET', '');
|
|
const { createHost, createEnrollment, enrollHost } = await import('../../src/lib/server/hosts/index.js');
|
|
hostId = createHost({ name: 'Safety test host' }).id;
|
|
enrollHost(createEnrollment(hostId).token, { protocolVersion: 1, dockerRunning: true, directoriesWritable: true,
|
|
storageAvailable: 100_000_000_000, runtimeImages: ['plutainer:test'], libraries: [{ id: 'published-game-version', state: 'ready' }] });
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
instanceId = randomUUID();
|
|
db().prepare("INSERT INTO servers(name,komodo_name,game,map,game_mode,map_slug,game_port,container_state) VALUES(?,'','t5','zombie_theater','zombie','theater',28960,'running')").run(server);
|
|
db().prepare(`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at) VALUES(?,?,?,'published-game-version','plutainer:test',28960,?)`).run(instanceId, server, hostId, new Date().toISOString());
|
|
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 remote mutation: ${kind}`);
|
|
return healthy as never;
|
|
});
|
|
});
|
|
afterEach(async () => { await fixture.cleanup(); vi.unstubAllEnvs(); vi.restoreAllMocks(); });
|
|
|
|
async function advance() {
|
|
const { processNativeProvisioning } = await import('../../src/lib/server/provisioning/native.js');
|
|
await processNativeProvisioning(() => null);
|
|
}
|
|
async function operationId(id: string) {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
return (db().prepare('SELECT operation_id FROM native_provisioning WHERE deployment_id=?').get(id) as { operation_id: string | null }).operation_id;
|
|
}
|
|
async function waitingRestart() {
|
|
const { requestLifecycle } = await import('../../src/lib/server/provisioning/requests.js');
|
|
const result = requestLifecycle(server, 'restart'); if (!result.ok) throw new Error(result.error);
|
|
return result.deploymentId;
|
|
}
|
|
async function suspendInspection() {
|
|
const { nativeRequest } = await import('../../src/lib/server/backends/native.js');
|
|
let release!: () => void;
|
|
vi.mocked(nativeRequest).mockImplementationOnce(async () => { await new Promise<void>(resolve => { release = resolve; }); return healthy as never; });
|
|
const running = advance();
|
|
await vi.waitFor(() => expect(release).toBeTypeOf('function'));
|
|
return { running, release };
|
|
}
|
|
async function stageCampaign() {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare("INSERT INTO deployments(id,action,started_at) VALUES('campaign-race','promote',?)").run(new Date().toISOString());
|
|
db().prepare("INSERT INTO deployment_servers(deployment_id,server,files_changed,restart_state) VALUES('campaign-race',?,1,'pending')").run(server);
|
|
const { queueFileChange } = await import('../../src/lib/server/fleet/staged-files.js');
|
|
queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('reviewed campaign bytes'), inventorySource: 'campaign:test' });
|
|
const { requestRestart } = await import('../../src/lib/server/provisioning/requests.js');
|
|
const restart = requestRestart(server, { kind: 'campaign', id: 'campaign-race' });
|
|
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 { reportHostOperation } = await import('../../src/lib/server/hosts/index.js');
|
|
reportHostOperation(hostId, staged.stage_operation_id, { state: 'completed', result: { state: 'staged' } });
|
|
return restart.id;
|
|
}
|
|
|
|
describe('native disruption safety at dispatch', () => {
|
|
it('lets fresh occupied telemetry veto an empty RCON result and resumes after that evidence expires', async () => {
|
|
const id = await waitingRestart();
|
|
const { saveTelemetry } = await import('../../src/lib/server/fleet/telemetry.js');
|
|
saveTelemetry({ event: 'game_start', server, map: 'zombie_theater', players: [{ name: 'Still playing', guid: 'player-1' }], receivedAt: new Date().toISOString() });
|
|
await advance();
|
|
expect(await operationId(id)).toBeNull();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
|
|
db().prepare('UPDATE server_telemetry SET received_at=? WHERE server=?').run('2000-01-01T00:00:00Z', server);
|
|
await advance();
|
|
expect(await operationId(id)).toBeTypeOf('string');
|
|
});
|
|
|
|
it('holds destruction if a beta becomes active while remote inspection is pending', async () => {
|
|
const { destroyServer } = await import('../../src/lib/server/provisioning/instances.js');
|
|
const removal = destroyServer(server); if (!removal.ok || !removal.deploymentId) throw new Error('Removal was not queued.');
|
|
const pending = await suspendInspection();
|
|
const { createScript } = await import('../../src/lib/server/fleet/scripts.js');
|
|
const { script, version } = createScript({ name: 'Race beta', kind: 'script', targetRelPath: 'beta.gsc', content: Buffer.from('main() {}'), scopeType: 'game', scopeValue: 't5' });
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const betaId = db().prepare("INSERT INTO betas(script_id,script_version_id,trigger_kind,state) VALUES(?,?,'manual','running')").run(script.id, version.id).lastInsertRowid;
|
|
db().prepare("INSERT INTO beta_servers(beta_id,server,had_original,stage) VALUES(?,?,0,'awaiting-empty')").run(betaId, server);
|
|
pending.release(); await pending.running;
|
|
expect(await operationId(removal.deploymentId)).toBeNull();
|
|
expect(db().prepare('SELECT name FROM servers WHERE name=?').get(server)).toEqual({ name: server });
|
|
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.remove'").get()).toEqual({ count: 0 });
|
|
});
|
|
|
|
it.each(['cancel', 'change bytes'] as const)('does not activate staged files after a %s during remote inspection', async change => {
|
|
const id = await stageCampaign();
|
|
const pending = await suspendInspection();
|
|
const { cancelStagedFiles, queueFileChange, stagedFileChanges } = await import('../../src/lib/server/fleet/staged-files.js');
|
|
if (change === 'cancel') cancelStagedFiles('campaign', 'campaign-race', server);
|
|
else queueFileChange({ sourceKind: 'campaign', sourceId: 'campaign-race', server, kind: 'script', relPath: 'hud.gsc', content: Buffer.from('different unreviewed bytes'), inventorySource: 'campaign:test' });
|
|
pending.release(); await pending.running;
|
|
expect(await operationId(id)).toBeNull();
|
|
expect(stagedFileChanges('campaign', 'campaign-race')[0].state).toBe(change === 'cancel' ? 'cancelled' : 'pending');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
|
|
});
|
|
|
|
it('does not dispatch an old generation if the instance changes during inspection', async () => {
|
|
const id = await waitingRestart();
|
|
const pending = await suspendInspection();
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
db().prepare('UPDATE native_instances SET generation=2 WHERE id=?').run(instanceId);
|
|
pending.release(); await pending.running;
|
|
expect(await operationId(id)).toBeNull();
|
|
expect(db().prepare("SELECT COUNT(*) AS count FROM ec_host_operations WHERE kind='instance.restart'").get()).toEqual({ count: 0 });
|
|
});
|
|
});
|