mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-10 17:32:28 +02:00
The host report gains gpus: each card's model, VRAM in MiB when the driver says, and the PCI id it was named by. Kept on the machine (a jsonb column, written only when it changes) and returned by /machine. What a person calls the machine, and the first measured part of the capacity a scheduler will place by.
204 lines
6.2 KiB
TypeScript
204 lines
6.2 KiB
TypeScript
import { afterAll, describe, expect, test } from 'bun:test';
|
||
|
||
import { Box } from '@nestri/core/box/index';
|
||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||
import { testDb } from '@nestri/core/db/test';
|
||
import { Identifier } from '@nestri/core/id';
|
||
import { Machine } from '@nestri/core/machine/index';
|
||
|
||
import { app } from '../app/index';
|
||
import './setup';
|
||
|
||
const sql = testDb();
|
||
|
||
const createdUserIds: string[] = [];
|
||
|
||
async function registeredHost(label: string) {
|
||
const owner = await Fixtures.owner(label);
|
||
createdUserIds.push(owner.userId);
|
||
const registered = await Machine.register({
|
||
id: Identifier.ascending('machine'),
|
||
ownerUserId: owner.userId,
|
||
teamId: owner.teamId,
|
||
label
|
||
});
|
||
return {
|
||
id: registered.id,
|
||
userId: owner.userId,
|
||
headers: {
|
||
'x-nestri-machine-id': registered.id,
|
||
'x-nestri-machine-secret': registered.secret,
|
||
'content-type': 'application/json'
|
||
}
|
||
};
|
||
}
|
||
|
||
async function boxOn(host: { id: string; userId: string }, label: string) {
|
||
return Box.create({
|
||
id: Identifier.ascending('box'),
|
||
userId: host.userId,
|
||
machineId: host.id,
|
||
label,
|
||
tier: 'sm'
|
||
});
|
||
}
|
||
|
||
function report(host: { headers: Record<string, string> }, body: unknown) {
|
||
return app.request('/machine/report', {
|
||
method: 'POST',
|
||
headers: host.headers,
|
||
body: JSON.stringify(body)
|
||
});
|
||
}
|
||
|
||
afterAll(async () => {
|
||
if (createdUserIds.length > 0) {
|
||
await sql`delete from "box" where user_id in ${sql(createdUserIds)}`;
|
||
await sql`delete from "user" where id in ${sql(createdUserIds)}`;
|
||
createdUserIds.length = 0;
|
||
}
|
||
});
|
||
|
||
describe('POST /machine/report', () => {
|
||
test('the body is the shape the agent sends, flat and camelCased', async () => {
|
||
const host = await registeredHost('report-shape');
|
||
const running = await boxOn(host, 'one');
|
||
const idle = await boxOn(host, 'two');
|
||
|
||
// Written from the contract and not from the handler: host fields flat at
|
||
// the top, boxes under `boxes`, the lifecycle tag flattened into each box,
|
||
// and `uptimeS` rather than `uptime_s`. A rename on either side of this
|
||
// seam produces a host that reports, is understood by nothing, and is
|
||
// told it succeeded.
|
||
const res = await report(host, {
|
||
agentPid: 4711,
|
||
boxesKnown: 2,
|
||
boxesRunning: 1,
|
||
boxes: [
|
||
{ boxId: running.id, tier: '1080p', state: 'running', pid: 1234, uptimeS: 45 },
|
||
{ boxId: idle.id, tier: 'sm', state: 'created' }
|
||
]
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
const body = (await res.json()) as any;
|
||
expect(body.data.recorded).toBe(2);
|
||
expect(body.data.unknown).toEqual([]);
|
||
expect((await Box.fromID(running.id))!.state).toBe('running');
|
||
});
|
||
|
||
test('the cards a host reports are kept on its machine', async () => {
|
||
const host = await registeredHost('report-gpus');
|
||
const gpus = [{ model: 'AMD Radeon RX 9060 XT', vramMib: 16304, pciId: '1002:7590' }];
|
||
|
||
const res = await report(host, {
|
||
agentPid: 1,
|
||
boxesKnown: 0,
|
||
boxesRunning: 0,
|
||
boxes: [],
|
||
gpus
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect((await Machine.fromID(host.id))!.gpus).toEqual(gpus);
|
||
|
||
// A report without them leaves what is known alone.
|
||
await report(host, { agentPid: 1, boxesKnown: 0, boxesRunning: 0, boxes: [] });
|
||
expect((await Machine.fromID(host.id))!.gpus).toEqual(gpus);
|
||
});
|
||
|
||
test('a request is not wrapped in an envelope, but the response is', async () => {
|
||
const host = await registeredHost('report-envelope');
|
||
|
||
const wrapped = await report(host, {
|
||
data: { agentPid: 1, boxesKnown: 0, boxesRunning: 0, boxes: [] }
|
||
});
|
||
expect(wrapped.status).toBe(400);
|
||
|
||
const flat = await report(host, {
|
||
agentPid: 1,
|
||
boxesKnown: 0,
|
||
boxesRunning: 0,
|
||
boxes: []
|
||
});
|
||
expect(flat.status).toBe(200);
|
||
expect(await flat.json()).toHaveProperty('data');
|
||
});
|
||
|
||
test('a stop carries its reason all the way to the row', async () => {
|
||
const host = await registeredHost('report-stop');
|
||
const box = await boxOn(host, 'faulty');
|
||
|
||
await report(host, {
|
||
agentPid: 1,
|
||
boxesKnown: 1,
|
||
boxesRunning: 0,
|
||
boxes: [
|
||
{ boxId: box.id, tier: 'sm', state: 'stopped', reason: 'guest exited 1', clean: false }
|
||
]
|
||
});
|
||
|
||
const row = (await Box.fromID(box.id))!;
|
||
expect(row.state).toBe('stopped');
|
||
expect(row.stopReason).toBe('guest exited 1');
|
||
expect(row.stopClean).toBe(false);
|
||
});
|
||
|
||
test('a host cannot report on another host’s boxes', async () => {
|
||
// The access rule that matters here. A machine credential is a long-lived
|
||
// secret on hardware in somebody's living room, so the scope is in the
|
||
// query and not in the agent asking politely about its own boxes.
|
||
const mine = await registeredHost('report-mine');
|
||
const theirs = await registeredHost('report-theirs');
|
||
const victim = await boxOn(theirs, 'not yours');
|
||
await Box.setState({ id: victim.id, state: 'running' });
|
||
|
||
const res = await report(mine, {
|
||
agentPid: 1,
|
||
boxesKnown: 1,
|
||
boxesRunning: 1,
|
||
boxes: [{ boxId: victim.id, tier: 'sm', state: 'stopped', reason: 'mine now', clean: false }]
|
||
});
|
||
|
||
expect(res.status).toBe(200);
|
||
expect(((await res.json()) as any).data.unknown).toEqual([victim.id]);
|
||
expect((await Box.fromID(victim.id))!.state).toBe('running');
|
||
});
|
||
|
||
test('a field nobody can act on is refused rather than dropped', async () => {
|
||
const host = await registeredHost('report-strict');
|
||
|
||
// Capacity belongs on this call eventually and has no honest fields yet.
|
||
// Refusing an invented one is how an unmeasured number stays out of a
|
||
// record a placement decision will one day read.
|
||
const res = await report(host, {
|
||
agentPid: 1,
|
||
boxesKnown: 0,
|
||
boxesRunning: 0,
|
||
boxes: [],
|
||
gpusFree: 4
|
||
});
|
||
expect(res.status).toBe(400);
|
||
});
|
||
|
||
test('wrong credentials are indistinguishable from none', async () => {
|
||
const host = await registeredHost('report-wrongsecret');
|
||
const body = JSON.stringify({ agentPid: 1, boxesKnown: 0, boxesRunning: 0, boxes: [] });
|
||
|
||
const none = await app.request('/machine/report', {
|
||
method: 'POST',
|
||
headers: { 'content-type': 'application/json' },
|
||
body
|
||
});
|
||
const wrong = await app.request('/machine/report', {
|
||
method: 'POST',
|
||
headers: { ...host.headers, 'x-nestri-machine-secret': 'msk_wrong' },
|
||
body
|
||
});
|
||
|
||
expect(wrong.status).toBe(403);
|
||
expect(none.status).toBe(403);
|
||
expect(await wrong.json()).toEqual(await none.json());
|
||
});
|
||
});
|