284 lines
12 KiB
TypeScript
284 lines
12 KiB
TypeScript
import { setImmediate } from 'node:timers/promises';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
import { newFixture, type Fixture } from '../helpers/fixture.js';
|
|
import type { CapacityData, CapacityQuery } from '../../src/lib/capacity.js';
|
|
|
|
let fixture: Fixture;
|
|
let hostId: string;
|
|
const GiB = 1024 ** 3;
|
|
const epoch = Date.parse('2026-10-05T12:00:00Z');
|
|
const image = 'capacity-test-runtime';
|
|
const query: CapacityQuery = { range: '1h', hostId: null, game: 'all' };
|
|
const inventory = {
|
|
protocolVersion: 1,
|
|
dockerRunning: true,
|
|
directoriesWritable: true,
|
|
storageAvailable: 100 * GiB,
|
|
cpuCount: 16,
|
|
cpuUsagePercent: 0,
|
|
memoryTotal: 64 * GiB,
|
|
memoryAvailable: 60 * GiB,
|
|
runtimeImages: [image],
|
|
libraries: [
|
|
{ id: 't5-version', state: 'ready' },
|
|
{ id: 't6-version', state: 'ready' }
|
|
]
|
|
};
|
|
|
|
async function settle() {
|
|
await setImmediate();
|
|
await setImmediate();
|
|
}
|
|
async function addHost(name: string, overrides = {}) {
|
|
const hosts = await import('../../src/lib/server/hosts/index.js');
|
|
const host = hosts.createHost({ name, portMin: 28960, portMax: 28962 });
|
|
hosts.enrollHost(hosts.createEnrollment(host.id).token, { ...inventory, ...overrides });
|
|
hosts.heartbeat(host.id, { ...inventory, ...overrides });
|
|
await settle();
|
|
return host.id;
|
|
}
|
|
|
|
beforeEach(async () => {
|
|
vi.resetModules();
|
|
vi.stubEnv('PLUTAINER_IMAGE', image);
|
|
fixture = await newFixture([]);
|
|
vi.useFakeTimers({ toFake: ['Date'] });
|
|
vi.setSystemTime(epoch);
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { ensureGameFileSchema } = await import('../../src/lib/server/game-files/schema.js');
|
|
ensureGameFileSchema(db());
|
|
for (const game of ['t5', 't6']) {
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO game_file_libraries(id,name,game,description,created_at) VALUES(?,?,?,?,?)'
|
|
)
|
|
.run(`${game}-library`, game, game, '', new Date(epoch).toISOString());
|
|
db()
|
|
.prepare(
|
|
`INSERT INTO game_file_versions
|
|
(id,library_id,version,description,dlc,state,created_at,published_at,storage_location,source_host_id,source_volume)
|
|
VALUES(?,?,?,'','[]','ready',?,?,'','','')`
|
|
)
|
|
.run(
|
|
`${game}-version`,
|
|
`${game}-library`,
|
|
'1',
|
|
new Date(epoch).toISOString(),
|
|
new Date(epoch).toISOString()
|
|
);
|
|
}
|
|
hostId = await addHost('Main host');
|
|
});
|
|
afterEach(async () => {
|
|
vi.restoreAllMocks();
|
|
vi.unstubAllEnvs();
|
|
vi.useRealTimers();
|
|
await fixture.cleanup();
|
|
});
|
|
|
|
describe('capacity overview', () => {
|
|
it('shares the cached launch estimate and counts a dual-game host only once without placement or history reads', async () => {
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
const { launchAvailability } = await import('../../src/lib/server/hosts/availability.js');
|
|
const placement = await import('../../src/lib/server/hosts/capacity.js');
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const choose = vi.spyOn(placement, 'choosePlacement');
|
|
const prepare = vi.spyOn(db(), 'prepare');
|
|
const current = await readCapacityCurrent(query);
|
|
const launch = launchAvailability(
|
|
Array.from({ length: 20 }, (_, index) => ({
|
|
image,
|
|
libraryId: index % 2 ? 't5-version' : 't6-version',
|
|
automatic: true
|
|
}))
|
|
).availability;
|
|
expect(current.summary).toMatchObject({
|
|
estimatedServers: 3,
|
|
eligibleHosts: 1,
|
|
totalHosts: 1,
|
|
portsAvailable: 3,
|
|
cpuCoresAvailable: launch.cpuCoresAvailable,
|
|
memoryBytesAvailable: launch.memoryBytesAvailable
|
|
});
|
|
expect(current.summary.estimatedServers).toBe(launch.estimatedAvailable);
|
|
expect(current.hosts[0]).toMatchObject({
|
|
games: ['t5', 't6'],
|
|
limitingResource: 'Ports',
|
|
calibration: {
|
|
running: { source: 'bootstrap' },
|
|
startup: { source: 'bootstrap' }
|
|
}
|
|
});
|
|
expect(choose).not.toHaveBeenCalled();
|
|
expect(
|
|
prepare.mock.calls.some(([sql]) =>
|
|
/ec_host_resource_samples|ec_instance_resource_samples|native_instances/.test(sql)
|
|
)
|
|
).toBe(false);
|
|
});
|
|
|
|
it('filters launch eligibility by game and host while keeping all hosts available in the selector', async () => {
|
|
const t5Only = await addHost('T5 only', {
|
|
libraries: [{ id: 't5-version', state: 'ready' }]
|
|
});
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
expect((await readCapacityCurrent(query)).summary).toMatchObject({
|
|
estimatedServers: 6,
|
|
eligibleHosts: 2
|
|
});
|
|
const t6 = await readCapacityCurrent({ ...query, game: 't6' });
|
|
expect(t6.summary).toMatchObject({ estimatedServers: 3, eligibleHosts: 1, totalHosts: 2 });
|
|
expect(t6.hosts.find((host) => host.id === t5Only)?.reasons.join(' ')).toContain(
|
|
'Latest game files'
|
|
);
|
|
const selected = await readCapacityCurrent({ ...query, game: 't5', hostId: t5Only });
|
|
expect(selected.summary).toMatchObject({
|
|
estimatedServers: 3,
|
|
eligibleHosts: 1,
|
|
totalHosts: 1
|
|
});
|
|
expect(selected.hosts.map((host) => host.id)).toEqual([t5Only]);
|
|
expect(selected.hostOptions.map((host) => host.id)).toEqual([hostId, t5Only]);
|
|
});
|
|
|
|
it('excludes stale and disabled hosts and does not show stale resource readings as current', async () => {
|
|
const other = await addHost('Disabled host');
|
|
const { updateHost, heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
updateHost(other, { mode: 'disabled' });
|
|
await settle();
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
expect((await readCapacityCurrent(query)).summary.eligibleHosts).toBe(1);
|
|
vi.setSystemTime(epoch + 91_000);
|
|
heartbeat(other, inventory);
|
|
const current = await readCapacityCurrent(query);
|
|
expect(current.summary).toMatchObject({
|
|
estimatedServers: 0,
|
|
cpuCoresAvailable: 0,
|
|
memoryBytesAvailable: 0,
|
|
portsAvailable: 0,
|
|
eligibleHosts: 0,
|
|
totalHosts: 2
|
|
});
|
|
expect(current.hosts.find((host) => host.id === hostId)).toMatchObject({
|
|
connected: false,
|
|
eligible: false,
|
|
cpuCoresAvailable: null,
|
|
memoryBytesAvailable: null
|
|
});
|
|
expect(current.hosts.find((host) => host.id === other)?.reasons.join(' ')).toContain(
|
|
'disabled'
|
|
);
|
|
});
|
|
|
|
it('preserves unknown measurements and distinguishes them from measured exhaustion', async () => {
|
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
heartbeat(hostId, { ...inventory, cpuUsagePercent: undefined, memoryAvailable: 65 * GiB });
|
|
const unknown = await readCapacityCurrent(query);
|
|
expect(unknown.summary).toMatchObject({
|
|
estimatedServers: null,
|
|
cpuCoresAvailable: null,
|
|
memoryBytesAvailable: null,
|
|
eligibleHosts: 1,
|
|
portsAvailable: 3
|
|
});
|
|
expect(unknown.hosts[0].limitingResource).toBeNull();
|
|
expect(unknown.warnings.join(' ')).toContain('unknown');
|
|
heartbeat(hostId, { ...inventory, cpuUsagePercent: 100 });
|
|
expect((await readCapacityCurrent(query)).hosts[0]).toMatchObject({
|
|
estimatedServers: 0,
|
|
cpuCoresAvailable: 0,
|
|
limitingResource: 'CPU'
|
|
});
|
|
});
|
|
|
|
it.each([
|
|
['CPU', { cpuUsagePercent: 90 }, 1],
|
|
['Memory', { memoryAvailable: GiB + 500_000_000 }, 1],
|
|
['Ports', {}, 3]
|
|
])(
|
|
'identifies %s as the resource limiting additional servers',
|
|
async (limitingResource, overrides, estimatedServers) => {
|
|
const { heartbeat } = await import('../../src/lib/server/hosts/index.js');
|
|
heartbeat(hostId, { ...inventory, ...overrides });
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
expect((await readCapacityCurrent(query)).hosts[0]).toMatchObject({
|
|
limitingResource,
|
|
estimatedServers
|
|
});
|
|
}
|
|
);
|
|
|
|
it('reserves OS headroom and unmeasured startups without double-counting TCP and UDP ports', async () => {
|
|
const { db } = await import('../../src/lib/server/db/index.js');
|
|
const { beginDeployment } = await import('../../src/lib/server/provisioning/history.js');
|
|
const { heartbeat, reserveHostPort } = await import('../../src/lib/server/hosts/index.js');
|
|
const deployment = beginDeployment('t5_zombie_theater_1', 'create');
|
|
db()
|
|
.prepare(
|
|
`INSERT INTO native_instances(id,server,host_id,library_id,image,port,created_at)
|
|
VALUES('starting','t5_zombie_theater_1',?,'t5-version',?,28960,?)`
|
|
)
|
|
.run(hostId, image, new Date(epoch).toISOString());
|
|
db()
|
|
.prepare(
|
|
'INSERT INTO native_provisioning(deployment_id,instance_id,generation) VALUES(?,?,1)'
|
|
)
|
|
.run(deployment, 'starting');
|
|
reserveHostPort(hostId, 'starting', { port: 28960, protocol: 'udp' });
|
|
reserveHostPort(hostId, 'starting', { port: 28960, protocol: 'tcp' });
|
|
heartbeat(hostId, inventory);
|
|
const { readCapacityCurrent } =
|
|
await import('../../src/lib/server/hosts/capacity-overview.js');
|
|
const current = await readCapacityCurrent(query);
|
|
expect(current.summary).toMatchObject({
|
|
cpuCoresAvailable: 14.7,
|
|
memoryBytesAvailable: 59 * GiB - 500_000_000,
|
|
portsAvailable: 2,
|
|
activeStartups: 1
|
|
});
|
|
heartbeat(hostId, {
|
|
...inventory,
|
|
instanceResources: [
|
|
{
|
|
instanceId: 'starting',
|
|
generation: 1,
|
|
uptimeSeconds: 30,
|
|
cpuCores: 0.1,
|
|
memoryBytes: GiB / 4
|
|
}
|
|
]
|
|
});
|
|
expect((await readCapacityCurrent(query)).summary).toMatchObject({
|
|
cpuCoresAvailable: 15.2,
|
|
memoryBytesAvailable: 60 * GiB - 500_000_000,
|
|
portsAvailable: 2
|
|
});
|
|
});
|
|
|
|
it('serves non-cacheable filtered history and current data and rejects unknown hosts', async () => {
|
|
const { GET } = await import('../../src/routes/api/capacity/+server.js');
|
|
const response = await GET({
|
|
url: new URL(`https://ec.test/api/capacity?range=1h&game=t6&host=${hostId}`)
|
|
});
|
|
expect(response.headers.get('cache-control')).toBe('no-store');
|
|
const result = (await response.json()) as CapacityData;
|
|
expect(result.query).toEqual({ ...query, game: 't6', hostId });
|
|
expect(result.current.hosts.map((host) => host.id)).toEqual([hostId]);
|
|
expect(result.history).toMatchObject({ range: '1h', bucketMs: 60_000 });
|
|
expect(result.history.points).toHaveLength(60);
|
|
const fallback = await GET({
|
|
url: new URL('https://ec.test/api/capacity?range=unbounded&game=invalid')
|
|
});
|
|
expect((await fallback.json()).query).toEqual({ range: '24h', game: 'all', hostId: null });
|
|
await expect(
|
|
GET({ url: new URL('https://ec.test/api/capacity?host=missing') })
|
|
).rejects.toMatchObject({ status: 404 });
|
|
});
|
|
});
|