mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(api): record what a host says it is running
A host agent already sends a full inventory snapshot on a cadence, and nothing served the endpoint it sends it to — so every one of those calls answered 404. It fails quietly by design, because a dropped snapshot is meant to be corrected by the next one, which is exactly why nobody noticed: the only symptom is a line in the agent's own log. Kept separate from the heartbeat because the two have different loss tolerance. A dropped beat moves a host towards offline and unplaces it; a dropped snapshot costs nothing until the next one arrives. Folding them together would let a malformed inventory field make a healthy host look dead. Three rules decide what a snapshot may do, and the last two are why this is one core function rather than a loop in the route: - a box we know, that the snapshot names, takes the reported state - a box we know that was running, and that the snapshot omits, is stopped and says so — absence inside a snapshot is information - a box the snapshot names that is not placed on the calling host is never created, only reported back as a divergence The scope is in the `where` clause and not in the agent asking politely about its own boxes: a machine credential is a long-lived secret sitting on hardware in somebody's living room. `pid` and `uptimeS` are accepted and deliberately dropped. A pid is a number in another machine's namespace, and uptime is derivable from a run's start time, which is already stored and already trustworthy.
This commit is contained in:
183
apps/api/test/report.test.ts
Normal file
183
apps/api/test/report.test.ts
Normal file
@@ -0,0 +1,183 @@
|
||||
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: 'sm', 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('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());
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user