mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
feat(api): rooms are GPUs and plans count rooms
A box records the GPU it is pinned to. GET /location lists your own
GPUs and the fleet's by model. POST /box takes { on: own, machineId,
pciId } or { on: fleet, model }; free holds 1 cloud room on entry
models, Pro 3 on any, own rooms are unlimited, and refusals are typed
(room_limit, gpu_needs_pro). The weekend trial and the usage windows no
longer decide anything and GET /billing reports plan and room counts.
Cloud rooms with nothing live for 3 hours are stopped when their host
polls for work.
This commit is contained in:
@@ -11,6 +11,7 @@ import { type ContentfulStatusCode } from 'hono/utils/http-status';
|
||||
import { auth } from './middleware/auth.js';
|
||||
import { AccessTokenApi } from './routes/access-token.js';
|
||||
import { BillingApi } from './routes/billing.js';
|
||||
import { LocationApi } from './routes/location';
|
||||
import { BoxApi } from './routes/box.js';
|
||||
import { EnrolmentApi } from './routes/enrolment.js';
|
||||
import { GameApi } from './routes/game.js';
|
||||
@@ -56,6 +57,7 @@ const routes = app
|
||||
.route('/machine', EnrolmentApi.route)
|
||||
.route('/team', TeamApi.route)
|
||||
.route('/box', BoxApi.route)
|
||||
.route('/location', LocationApi.route)
|
||||
.route('/session', SessionApi.route)
|
||||
.route('/access-token', AccessTokenApi.route)
|
||||
.route('/waitlist', WaitlistApi.route)
|
||||
|
||||
@@ -144,9 +144,9 @@ export namespace BillingApi {
|
||||
notPublic,
|
||||
describeRoute({
|
||||
tags: ['Billing'],
|
||||
summary: 'What you are on, and what you have spent',
|
||||
summary: 'What you are on, and how many rooms it allows',
|
||||
description:
|
||||
'The plan, and where each of the three windows stands. This is what a meter is drawn from — the percentages here are the same numbers that decide whether a run may start, so a full bar and a refusal cannot disagree.',
|
||||
'The plan, and cloud rooms used against allowed. Rooms are the only limit a plan sets; there are no time windows.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Billing.State) } },
|
||||
@@ -156,7 +156,13 @@ export namespace BillingApi {
|
||||
404: ErrorResponses[404]
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await Billing.state({ teamId: await payingTeam() }) })
|
||||
async (c) =>
|
||||
c.json({
|
||||
data: await Billing.state({
|
||||
teamId: await payingTeam(),
|
||||
userId: Actor.userID
|
||||
})
|
||||
})
|
||||
)
|
||||
.post(
|
||||
'/checkout',
|
||||
@@ -232,7 +238,9 @@ export namespace BillingApi {
|
||||
}),
|
||||
async (c) => {
|
||||
mustBeConfigured();
|
||||
return c.json({ data: await Polar.portal({ teamId: await payingTeam() }) });
|
||||
return c.json({
|
||||
data: await Polar.portal({ teamId: await payingTeam() })
|
||||
});
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
+45
-14
@@ -2,10 +2,12 @@ import { Actor } from '@nestri/core/actor';
|
||||
import { BoxTier } from '@nestri/core/box/box.sql';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { Placement } from '@nestri/core/box/placement';
|
||||
import { Env } from '@nestri/core/env';
|
||||
import { Billing } from '@nestri/core/billing/index';
|
||||
import { Rooms } from '@nestri/core/billing/rooms';
|
||||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
@@ -25,7 +27,7 @@ export namespace BoxApi {
|
||||
tags: ['Box'],
|
||||
summary: 'Create a box',
|
||||
description:
|
||||
'A box on your own host (`on: "own"`, the default; refused unless you have exactly one) or on a Nestri GPU (`on: "fleet"`). You hold at most one box on Nestri GPUs; asking again is a 409.',
|
||||
'A room on a GPU. `{ on: "own", machineId, pciId }` puts it on one of your own GPUs (unlimited). `{ on: "fleet", model }` puts it on a Nestri GPU of that model; free allows 1 cloud room on entry models, Pro 3 on any. Over that, 403 with code `room_limit` or `gpu_needs_pro`. Without `machineId` an own room needs exactly one registered host, and a fleet room without `model` takes the least-loaded machine.',
|
||||
responses: {
|
||||
201: {
|
||||
content: { 'application/json': { schema: Result(Box.Info) } },
|
||||
@@ -33,6 +35,7 @@ export namespace BoxApi {
|
||||
},
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
403: ErrorResponses[403],
|
||||
404: ErrorResponses[404],
|
||||
409: ErrorResponses[409]
|
||||
}
|
||||
@@ -42,26 +45,51 @@ export namespace BoxApi {
|
||||
z.object({
|
||||
label: z.string().trim().min(1).max(64).default('box'),
|
||||
tier: z.enum(BoxTier.enumValues).default('md'),
|
||||
on: z.enum(['own', 'fleet']).default('own')
|
||||
on: z.enum(['own', 'fleet']).default('own'),
|
||||
machineId: z.string().optional().meta({
|
||||
description: 'With `on: "own"`: the host holding the GPU. Needs `pciId`'
|
||||
}),
|
||||
pciId: z.string().optional().meta({
|
||||
description: 'With `on: "own"`: the GPU on that host, as `GET /location` lists it'
|
||||
}),
|
||||
model: z.string().optional().meta({
|
||||
description: 'With `on: "fleet"`: the GPU model, as `GET /location` lists it'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { label, tier, on } = c.req.valid('json');
|
||||
const { label, tier, on, machineId, pciId, model } = c.req.valid('json');
|
||||
const userId = Actor.userID;
|
||||
if (on === 'own' && Boolean(machineId) !== Boolean(pciId)) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.MISSING_REQUIRED_FIELD,
|
||||
'Name both `machineId` and `pciId`, or neither',
|
||||
machineId ? 'pciId' : 'machineId'
|
||||
);
|
||||
}
|
||||
if (on === 'fleet') {
|
||||
const organisationId = Env.get().FLEET_ORGANISATION_ID;
|
||||
const held = organisationId ? await Box.onFleet({ userId, organisationId }) : null;
|
||||
if (held) {
|
||||
throw new VisibleError(
|
||||
'already_exists',
|
||||
ErrorCodes.Validation.ALREADY_EXISTS,
|
||||
`You already have a box on Nestri GPUs: ${held.id}`
|
||||
);
|
||||
// Plans count cloud rooms; your own GPUs are never limited.
|
||||
const team = await Team.personalFor(userId);
|
||||
const plan = team?.plan ?? 'free';
|
||||
if (model && !Rooms.mayUseModel(plan, model)) {
|
||||
throw Rooms.needsPro(model);
|
||||
}
|
||||
if ((await Billing.cloudRooms(userId)) >= Rooms.allowed(plan)) {
|
||||
throw Rooms.atLimit(model ?? null, plan);
|
||||
}
|
||||
}
|
||||
const placer =
|
||||
on === 'fleet'
|
||||
? model
|
||||
? Placement.onFleetModel(model)
|
||||
: Placement.fleet
|
||||
: machineId && pciId
|
||||
? Placement.onOwn({ machineId, pciId })
|
||||
: Placement.onlyHost;
|
||||
const box = await Box.createPlaced(
|
||||
{ id: Identifier.ascending('box'), userId, label, tier },
|
||||
on === 'fleet' ? Placement.fleet : Placement.onlyHost
|
||||
placer
|
||||
);
|
||||
return c.json({ data: box }, 201);
|
||||
}
|
||||
@@ -78,7 +106,10 @@ export namespace BoxApi {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] })
|
||||
z.array(Box.Info).meta({
|
||||
description: 'Your boxes',
|
||||
example: [Examples.Box]
|
||||
})
|
||||
)
|
||||
}
|
||||
},
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Actor } from '@nestri/core/actor';
|
||||
import { Location } from '@nestri/core/box/location';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
|
||||
import { ErrorResponses, notPublic, Result } from '../utils';
|
||||
|
||||
/** Where a room can be created. */
|
||||
export namespace LocationApi {
|
||||
export const route = new Hono().use(notPublic).get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Location'],
|
||||
summary: 'GPUs a room can go on',
|
||||
description:
|
||||
'Your own GPUs, one entry per card your hosts report, and the Nestri GPUs grouped by model. For each model, `available` says whether one is free now, `entry` whether it is an entry model, and `allowed` whether your plan may create a room on it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Location.Info) } },
|
||||
description: 'Your locations'
|
||||
},
|
||||
401: ErrorResponses[401]
|
||||
}
|
||||
}),
|
||||
async (c) => {
|
||||
const userId = Actor.userID;
|
||||
const plan = (await Team.personalFor(userId))?.plan ?? 'free';
|
||||
return c.json({ data: await Location.list({ userId, plan }) });
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -9,7 +9,6 @@ import { Identifier } from '@nestri/core/id';
|
||||
import { Session } from '@nestri/core/session/index';
|
||||
import { Launch } from '@nestri/core/session/launch';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
import { Trial } from '@nestri/core/trial/index';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
import { LinkedAccount } from '@nestri/core/user/linked-account';
|
||||
import { Hono } from 'hono';
|
||||
@@ -127,7 +126,9 @@ export namespace SessionApi {
|
||||
'At most one per box, newest first. Read this when asking for a run is refused because a box is busy: the run holding it may be the same game, which a client can rejoin by polling it, or another one it can offer to stop.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(Session.Info.array()) } },
|
||||
content: {
|
||||
'application/json': { schema: Result(Session.Info.array()) }
|
||||
},
|
||||
description: 'The runs still going, or still asked for'
|
||||
},
|
||||
401: ErrorResponses[401],
|
||||
@@ -236,7 +237,10 @@ export namespace SessionApi {
|
||||
// The library is also a synced copy, so this refuses a game
|
||||
// bought since the last sync. Both gaps let a launch fail late;
|
||||
// neither is a reason to start runs already known to fail.
|
||||
const owned = await Library.findByUserAndGame({ userId, gameId: game.id });
|
||||
const owned = await Library.findByUserAndGame({
|
||||
userId,
|
||||
gameId: game.id
|
||||
});
|
||||
if (!owned) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
@@ -283,38 +287,17 @@ export namespace SessionApi {
|
||||
conflict(Session.BOX_BUSY);
|
||||
}
|
||||
|
||||
// The allowance is checked here and nowhere later. A limit refuses
|
||||
// the next run; it never stops one already going, so this is the
|
||||
// only moment it may speak. The answer comes back rather than
|
||||
// being discarded, because the caller has to be told what it will
|
||||
// cost and what remains — and asking a second time would let the
|
||||
// number shown and the number billed disagree.
|
||||
//
|
||||
// On rented GPUs without a plan, the free weekend decides instead,
|
||||
// and the run is marked as a trial so it is counted, and stopped,
|
||||
// by the trial's rules rather than the allowance's.
|
||||
// Plans count rooms, not time: nothing here refuses a run for time
|
||||
// spent. The standing is returned so a client can show it.
|
||||
const payer = await Billing.teamForBox(box.id);
|
||||
const trial =
|
||||
payer?.hostClass === 'fleet' && (await Team.fromID(payer.teamId))?.plan !== 'paid';
|
||||
if (trial) {
|
||||
await Trial.assertMayStart({ teamId: payer.teamId, userId, linkedAccountId });
|
||||
}
|
||||
const billing =
|
||||
payer && !trial
|
||||
? await Billing.assertMayStart({
|
||||
teamId: payer.teamId,
|
||||
nextTier: body.tier ?? payer.tier,
|
||||
nextHostClass: payer.hostClass
|
||||
})
|
||||
: null;
|
||||
const billing = payer ? await Billing.state({ teamId: payer.teamId, userId }) : null;
|
||||
|
||||
const session = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
linkedAccountId,
|
||||
tier: body.tier,
|
||||
trial
|
||||
tier: body.tier
|
||||
});
|
||||
return c.json({ data: session, billing }, 201);
|
||||
}
|
||||
@@ -340,7 +323,10 @@ export namespace SessionApi {
|
||||
validator(
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({ description: 'The run to read', example: Examples.Session.id })
|
||||
id: z.string().meta({
|
||||
description: 'The run to read',
|
||||
example: Examples.Session.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
@@ -497,7 +483,9 @@ export namespace SessionApi {
|
||||
}),
|
||||
validator(
|
||||
'param',
|
||||
z.object({ id: z.string().meta({ description: 'The run', example: Examples.Session.id }) })
|
||||
z.object({
|
||||
id: z.string().meta({ description: 'The run', example: Examples.Session.id })
|
||||
})
|
||||
),
|
||||
validator(
|
||||
'json',
|
||||
@@ -521,7 +509,10 @@ export namespace SessionApi {
|
||||
);
|
||||
}
|
||||
// The same check, and the same gaps, as asking for a run.
|
||||
const owned = await Library.findByUserAndGame({ userId, gameId: game.id });
|
||||
const owned = await Library.findByUserAndGame({
|
||||
userId,
|
||||
gameId: game.id
|
||||
});
|
||||
if (!owned) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
@@ -574,9 +565,10 @@ export namespace SessionApi {
|
||||
'param',
|
||||
z.object({
|
||||
id: z.string().meta({ description: 'The run', example: Examples.Session.id }),
|
||||
launchId: z
|
||||
.string()
|
||||
.meta({ description: 'The launch to end', example: Examples.Launch.id })
|
||||
launchId: z.string().meta({
|
||||
description: 'The launch to end',
|
||||
example: Examples.Launch.id
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
@@ -738,7 +730,9 @@ export namespace SessionApi {
|
||||
'Returns the work for the calling host, and only that host — it comes from its own credentials and the scope is the query, so a box cannot see work for another. `session.start` is a run waiting to be started; `session.stop` is a run to end now, because its owner asked, its last launch is over, or the terms it started under ran out, repeated on every poll until the host reports it ended. `launch.start` is another game to start in a box that is already live; `launch.stop` is one launch to end, both repeated until the host reports that launch. An agent skips a kind it does not know. With `wait`, a call that finds nothing is held open until work appears or the wait is up, then answers empty, so a host is handed work within a moment of it being asked for; poll again straight away. Add `seen` and a call is held, too, while all the work it would answer is work the host already has, since a stop is repeated until reported. An API that cannot hold a call answers at once whatever `wait` says, and then the cadence the heartbeat hands down is the one to poll at.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Session.Job)) } },
|
||||
content: {
|
||||
'application/json': { schema: Result(z.array(Session.Job)) }
|
||||
},
|
||||
description: 'Work for this host: runs to end first, then runs to start, oldest first'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
@@ -757,16 +751,11 @@ export namespace SessionApi {
|
||||
description: `Seconds to hold the call open when there is no work, at most ${MAX_WAIT_SECONDS}. A larger value is held for the most; absent or 0 answers at once`,
|
||||
example: 25
|
||||
}),
|
||||
seen: z
|
||||
.string()
|
||||
.max(4096)
|
||||
.optional()
|
||||
.catch(undefined)
|
||||
.meta({
|
||||
description:
|
||||
'Comma-separated keys of the jobs the host already has, from its last answer: `kind/sessionId`, and `kind/sessionId/launchId` for a launch job. With `wait`, work whose key is listed is not new, so a call is held while all of it is; at the end of the wait the answer is the full list. Absent, every job is new',
|
||||
example: 'session.stop/ses_1,launch.stop/ses_1/lch_1'
|
||||
})
|
||||
seen: z.string().max(4096).optional().catch(undefined).meta({
|
||||
description:
|
||||
'Comma-separated keys of the jobs the host already has, from its last answer: `kind/sessionId`, and `kind/sessionId/launchId` for a launch job. With `wait`, work whose key is listed is not new, so a call is held while all of it is; at the end of the wait the answer is the full list. Absent, every job is new',
|
||||
example: 'session.stop/ses_1,launch.stop/ses_1/lch_1'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
|
||||
@@ -129,13 +129,14 @@ describe('POST /box on the fleet', () => {
|
||||
expect(((await res.json()) as any).message).toContain('not available');
|
||||
});
|
||||
|
||||
test('one fleet box each: asking again is a 409', async () => {
|
||||
test('a free plan holds one cloud room: asking again is refused with room_limit', async () => {
|
||||
const f = await fleet(1);
|
||||
useFleet(f.organisationId);
|
||||
const p = await person('fleet-twice');
|
||||
expect((await createBox(p.headers, { on: 'fleet' })).status).toBe(201);
|
||||
const again = await createBox(p.headers, { on: 'fleet' });
|
||||
expect(again.status).toBe(409);
|
||||
expect(again.status).toBe(403);
|
||||
expect(((await again.json()) as any).code).toBe('room_limit');
|
||||
expect((await Box.listByUser(p.owner.userId)).length).toBe(1);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,268 @@
|
||||
import { afterAll, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Box } from '@nestri/core/box/index';
|
||||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||||
import { testDb } from '@nestri/core/db/test';
|
||||
import { Env } from '@nestri/core/env';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { Team } from '@nestri/core/team/index';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import { TEST_FRONTEND_URL } from './setup';
|
||||
|
||||
const sql = testDb();
|
||||
const createdUserIds: string[] = [];
|
||||
const createdOrgIds: string[] = [];
|
||||
|
||||
const V710 = { model: 'AMD Radeon Pro V710', vramMib: 28672, pciId: '1002:7461' };
|
||||
const BIG = { model: 'NVIDIA H100', vramMib: 81920, pciId: '10de:2330' };
|
||||
|
||||
function useFleet(organisationId: string, entry?: string) {
|
||||
Env.init({
|
||||
NODE_ENV: 'test',
|
||||
FRONTEND_URL: TEST_FRONTEND_URL,
|
||||
FLEET_ORGANISATION_ID: organisationId,
|
||||
...(entry !== undefined ? { FLEET_ENTRY_GPUS: entry } : {})
|
||||
});
|
||||
}
|
||||
|
||||
async function person(label: string, plan?: 'paid') {
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
if (plan) {
|
||||
await Team.setPlan({ id: owner.teamId, plan, subscriptionStatus: 'active' });
|
||||
}
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: null,
|
||||
name: label
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
headers: {
|
||||
authorization: `Bearer ${pat.token}`,
|
||||
'content-type': 'application/json'
|
||||
} as Record<string, string>
|
||||
};
|
||||
}
|
||||
|
||||
async function fleet(gpusPerMachine: (typeof V710)[][]) {
|
||||
const operator = await Fixtures.owner('operator');
|
||||
createdUserIds.push(operator.userId);
|
||||
const f = await Fixtures.fleet(operator, gpusPerMachine.length);
|
||||
createdOrgIds.push(f.organisationId);
|
||||
for (const [i, gpus] of gpusPerMachine.entries()) {
|
||||
await Machine.setGpus({ id: f.machines[i]!.id, gpus });
|
||||
}
|
||||
useFleet(f.organisationId);
|
||||
return f;
|
||||
}
|
||||
|
||||
const post = (headers: Record<string, string>, body: object) =>
|
||||
app.request('/box', { method: 'POST', headers, body: JSON.stringify(body) });
|
||||
const json = async (res: Response) => (await res.json()) as any;
|
||||
|
||||
afterAll(async () => {
|
||||
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from box where user_id = any(${createdUserIds})`;
|
||||
await sql`delete from install_token where created_by_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from organisation where id = any(${createdOrgIds})`;
|
||||
await sql`delete from "user" where id = any(${createdUserIds})`;
|
||||
});
|
||||
|
||||
describe('GET /location', () => {
|
||||
test('lists your own GPUs one by one and the fleet grouped by model, per your plan', async () => {
|
||||
await fleet([[V710], [V710], [BIG]]);
|
||||
const free = await person('loc-free');
|
||||
const host = await Fixtures.machine(free.owner, 'loc-host');
|
||||
await Machine.setGpus({
|
||||
id: host,
|
||||
gpus: [
|
||||
{ model: 'NVIDIA GeForce RTX 3060', vramMib: 12288, pciId: '10de:2503' },
|
||||
{ model: 'AMD Radeon RX 9060 XT', vramMib: 16304, pciId: '1002:7590' }
|
||||
]
|
||||
});
|
||||
|
||||
const res = await app.request('/location', { headers: free.headers });
|
||||
expect(res.status).toBe(200);
|
||||
const { data } = await json(res);
|
||||
expect(data.own.map((o: any) => [o.machineId, o.pciId, o.model, o.vramMib, o.online])).toEqual([
|
||||
[host, '10de:2503', 'NVIDIA GeForce RTX 3060', 12288, false],
|
||||
[host, '1002:7590', 'AMD Radeon RX 9060 XT', 16304, false]
|
||||
]);
|
||||
expect(data.fleet).toEqual([
|
||||
{ model: V710.model, vramMib: 28672, available: true, entry: true, allowed: true },
|
||||
{ model: BIG.model, vramMib: 81920, available: true, entry: false, allowed: false }
|
||||
]);
|
||||
|
||||
const pro = await person('loc-pro', 'paid');
|
||||
const proFleet = (await json(await app.request('/location', { headers: pro.headers }))).data
|
||||
.fleet;
|
||||
expect(proFleet.every((f: any) => f.allowed)).toBe(true);
|
||||
expect(proFleet.find((f: any) => f.model === BIG.model).entry).toBe(false);
|
||||
});
|
||||
|
||||
test('a model with every card taken is listed as not available', async () => {
|
||||
await fleet([[V710]]);
|
||||
const p = await person('loc-taken');
|
||||
expect((await post(p.headers, { on: 'fleet', model: V710.model })).status).toBe(201);
|
||||
const other = await person('loc-other', 'paid');
|
||||
const { data } = await json(await app.request('/location', { headers: other.headers }));
|
||||
expect(data.fleet[0].available).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /box on a chosen GPU', () => {
|
||||
test('an own room is pinned to the chosen GPU; several own hosts are fine', async () => {
|
||||
const p = await person('own-gpu');
|
||||
const a = await Fixtures.machine(p.owner, 'host-a');
|
||||
const b = await Fixtures.machine(p.owner, 'host-b');
|
||||
await Machine.setGpus({ id: a, gpus: [V710] });
|
||||
await Machine.setGpus({ id: b, gpus: [BIG, V710] });
|
||||
|
||||
const res = await post(p.headers, { on: 'own', machineId: b, pciId: BIG.pciId });
|
||||
expect(res.status).toBe(201);
|
||||
const box = (await json(res)).data;
|
||||
expect(box.machineId).toBe(b);
|
||||
expect(box.gpuPciId).toBe(BIG.pciId);
|
||||
expect((await Box.fromID(box.id))!.gpuPciId).toBe(BIG.pciId);
|
||||
|
||||
// Own rooms are never counted against a plan.
|
||||
for (let i = 0; i < 4; i++) {
|
||||
expect((await post(p.headers, { on: 'own', machineId: a, pciId: V710.pciId })).status).toBe(
|
||||
201
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('an own room needs a GPU the host reports, on a host that is yours', async () => {
|
||||
const p = await person('own-bad');
|
||||
const stranger = await person('own-stranger');
|
||||
const mine = await Fixtures.machine(p.owner, 'mine');
|
||||
const theirs = await Fixtures.machine(stranger.owner, 'theirs');
|
||||
await Machine.setGpus({ id: mine, gpus: [V710] });
|
||||
await Machine.setGpus({ id: theirs, gpus: [V710] });
|
||||
|
||||
expect((await post(p.headers, { on: 'own', machineId: mine, pciId: BIG.pciId })).status).toBe(
|
||||
404
|
||||
);
|
||||
expect((await post(p.headers, { on: 'own', machineId: theirs, pciId: V710.pciId })).status).toBe(
|
||||
404
|
||||
);
|
||||
expect((await post(p.headers, { on: 'own', machineId: mine })).status).toBe(400);
|
||||
});
|
||||
|
||||
test('the old body still works: one host, no GPU named', async () => {
|
||||
const p = await person('old-body');
|
||||
const host = await Fixtures.machine(p.owner, 'old');
|
||||
const res = await post(p.headers, { label: 'den' });
|
||||
expect(res.status).toBe(201);
|
||||
const box = (await json(res)).data;
|
||||
expect(box.machineId).toBe(host);
|
||||
expect(box.gpuPciId).toBeNull();
|
||||
});
|
||||
|
||||
test('a fleet room is placed by model on the least-loaded machine with one free', async () => {
|
||||
const f = await fleet([[V710], [V710]]);
|
||||
const a = await person('fleet-model-a');
|
||||
const b = await person('fleet-model-b');
|
||||
const boxA = (await json(await post(a.headers, { on: 'fleet', model: V710.model }))).data;
|
||||
const boxB = (await json(await post(b.headers, { on: 'fleet', model: V710.model }))).data;
|
||||
expect(boxA.gpuPciId).toBe(V710.pciId);
|
||||
expect(new Set([boxA.machineId, boxB.machineId])).toEqual(new Set(f.machines.map((m) => m.id)));
|
||||
|
||||
// Both cards are now taken.
|
||||
const c = await person('fleet-model-c');
|
||||
expect((await post(c.headers, { on: 'fleet', model: V710.model })).status).toBe(429);
|
||||
});
|
||||
|
||||
test('free: one cloud room, entry models only', async () => {
|
||||
await fleet([[V710], [BIG], [V710]]);
|
||||
const p = await person('free-limits');
|
||||
|
||||
const big = await post(p.headers, { on: 'fleet', model: BIG.model });
|
||||
expect(big.status).toBe(403);
|
||||
const refusal = await json(big);
|
||||
expect(refusal.code).toBe('gpu_needs_pro');
|
||||
expect(refusal.message).toBe(`Upgrade to create a room on ${BIG.model}`);
|
||||
|
||||
expect((await post(p.headers, { on: 'fleet', model: V710.model })).status).toBe(201);
|
||||
const second = await post(p.headers, { on: 'fleet', model: V710.model });
|
||||
expect(second.status).toBe(403);
|
||||
const limit = await json(second);
|
||||
expect(limit.code).toBe('room_limit');
|
||||
expect(limit.message).toBe(`Upgrade to create a room on ${V710.model}`);
|
||||
|
||||
// Your own GPU is still open at the limit.
|
||||
const host = await Fixtures.machine(p.owner, 'free-own');
|
||||
await Machine.setGpus({ id: host, gpus: [BIG] });
|
||||
expect((await post(p.headers, { on: 'own', machineId: host, pciId: BIG.pciId })).status).toBe(
|
||||
201
|
||||
);
|
||||
});
|
||||
|
||||
test('pro: three cloud rooms on any model, then room_limit', async () => {
|
||||
await fleet([[BIG], [BIG], [BIG], [BIG]]);
|
||||
const p = await person('pro-limits', 'paid');
|
||||
for (let i = 0; i < 3; i++) {
|
||||
const res = await post(p.headers, { on: 'fleet', model: BIG.model });
|
||||
expect(res.status).toBe(201);
|
||||
}
|
||||
const over = await post(p.headers, { on: 'fleet', model: BIG.model });
|
||||
expect(over.status).toBe(403);
|
||||
expect((await json(over)).code).toBe('room_limit');
|
||||
|
||||
const billing = await json(await app.request('/billing', { headers: p.headers }));
|
||||
expect(billing.data.plan).toBe('paid');
|
||||
expect(billing.data.rooms).toEqual({ used: 3, allowed: 3 });
|
||||
});
|
||||
|
||||
test('the entry list is configuration', async () => {
|
||||
const f = await fleet([[BIG]]);
|
||||
useFleet(f.organisationId, 'h100');
|
||||
const p = await person('entry-config');
|
||||
expect((await post(p.headers, { on: 'fleet', model: BIG.model })).status).toBe(201);
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /billing', () => {
|
||||
test('answers plan and rooms, with no windows', async () => {
|
||||
await fleet([[V710]]);
|
||||
const p = await person('billing-shape');
|
||||
const { data } = await json(await app.request('/billing', { headers: p.headers }));
|
||||
expect(Object.keys(data).sort()).toEqual(['plan', 'rooms', 'teamId']);
|
||||
expect(data.rooms).toEqual({ used: 0, allowed: 1 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the idle limit', () => {
|
||||
test('a cloud room with nothing live for 3 hours is stopped, and its record stays', async () => {
|
||||
const f = await fleet([[V710]]);
|
||||
const p = await person('idle');
|
||||
const box = (await json(await post(p.headers, { on: 'fleet', model: V710.model }))).data;
|
||||
await Box.setState({ id: box.id, state: 'running' });
|
||||
|
||||
// Fresh: left alone.
|
||||
expect(await Box.stopIdle(f.machines[0]!.id)).toEqual([]);
|
||||
|
||||
await sql`update box set time_created = now() - interval '4 hours' where id = ${box.id}`;
|
||||
expect(await Box.stopIdle(f.machines[0]!.id)).toEqual([box.id]);
|
||||
const after = (await Box.fromID(box.id))!;
|
||||
expect(after.state).toBe('stopped');
|
||||
expect(after.stopReason).toBe(Box.IDLE_REASON);
|
||||
});
|
||||
|
||||
test('your own hardware has no idle limit', async () => {
|
||||
const p = await person('idle-own');
|
||||
const host = await Fixtures.machine(p.owner, 'idle-own-host');
|
||||
const box = (await json(await post(p.headers, { label: 'x' }))).data;
|
||||
await Box.setState({ id: box.id, state: 'running' });
|
||||
await sql`update box set time_created = now() - interval '9 hours' where id = ${box.id}`;
|
||||
expect(await Box.stopIdle(host)).toEqual([]);
|
||||
expect((await Box.fromID(box.id))!.state).toBe('running');
|
||||
});
|
||||
});
|
||||
@@ -142,16 +142,9 @@ describe('POST /session', () => {
|
||||
// start a run has to show what it costs and what remains, and a second
|
||||
// call for that is a call nobody makes.
|
||||
expect(Object.keys(body)).toEqual(['data', 'billing']);
|
||||
expect(body.billing.exhausted).toBe(false);
|
||||
expect(body.billing.windows.map((w: { window: string }) => w.window)).toEqual([
|
||||
'fiveHour',
|
||||
'sevenDay',
|
||||
'thirtyDay'
|
||||
]);
|
||||
// Nothing is live yet, so nothing is being spent — and one more run
|
||||
// would cost exactly one unit per second.
|
||||
expect(body.billing.rateMilli).toBe(0);
|
||||
expect(body.billing.rateMilliIfOneMore).toBe(1000);
|
||||
// Plans count rooms; there are no time windows to report.
|
||||
expect(Object.keys(body.billing).sort()).toEqual(['plan', 'rooms', 'teamId']);
|
||||
expect(body.billing.rooms.allowed).toBe(1);
|
||||
expect(body.data).toEqual({
|
||||
id: body.data.id,
|
||||
boxId: s.box.id,
|
||||
|
||||
@@ -1,302 +0,0 @@
|
||||
import { afterAll, afterEach, describe, expect, test } from 'bun:test';
|
||||
|
||||
import { AccessToken } from '@nestri/core/access-token/index';
|
||||
import { Fixtures } from '@nestri/core/db/fixtures';
|
||||
import { testDb } from '@nestri/core/db/test';
|
||||
import { Env } from '@nestri/core/env';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Trial } from '@nestri/core/trial/index';
|
||||
import { Library } from '@nestri/core/user/library';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import { TEST_FRONTEND_URL } from './setup';
|
||||
|
||||
const sql = testDb();
|
||||
const createdUserIds: string[] = [];
|
||||
const createdOrgIds: string[] = [];
|
||||
const createdGameIds: string[] = [];
|
||||
|
||||
/** Times on the clock the trial keeps, written out as instants. */
|
||||
const WEDNESDAY = new Date('2026-10-07T12:00:00Z');
|
||||
const SATURDAY = new Date('2026-10-10T12:00:00Z');
|
||||
|
||||
function at(instant: Date) {
|
||||
Trial.useClock(() => instant);
|
||||
}
|
||||
|
||||
afterEach(() => Trial.useClock(() => new Date()));
|
||||
|
||||
afterAll(async () => {
|
||||
await sql`delete from trial_claim where user_id = any(${createdUserIds}) or email like '%@trial.example.test'`;
|
||||
await sql`delete from burn_segment where session_id in (select s.id from session s join box b on b.id = s.box_id where b.user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from session where box_id in (select id from box where user_id = any(${createdUserIds}))`;
|
||||
await sql`delete from box where user_id = any(${createdUserIds})`;
|
||||
await sql`delete from machine where owner_user_id = any(${createdUserIds})`;
|
||||
await sql`delete from organisation where id = any(${createdOrgIds})`;
|
||||
await sql`delete from game where id = any(${createdGameIds})`;
|
||||
await sql`delete from "user" where id = any(${createdUserIds})`;
|
||||
});
|
||||
|
||||
/** A fleet of one, a person with a box on it and a game to play, and both credentials. */
|
||||
async function scene(label: string) {
|
||||
const operator = await Fixtures.owner(`${label}-op`);
|
||||
createdUserIds.push(operator.userId);
|
||||
const fleet = await Fixtures.fleet(operator, 1);
|
||||
createdOrgIds.push(fleet.organisationId);
|
||||
Env.init({
|
||||
NODE_ENV: 'test',
|
||||
FRONTEND_URL: TEST_FRONTEND_URL,
|
||||
FLEET_ORGANISATION_ID: fleet.organisationId
|
||||
});
|
||||
|
||||
const owner = await Fixtures.owner(label);
|
||||
createdUserIds.push(owner.userId);
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: owner.userId,
|
||||
teamId: null,
|
||||
name: label
|
||||
});
|
||||
const user = { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' };
|
||||
|
||||
const boxRes = await app.request('/box', {
|
||||
method: 'POST',
|
||||
headers: user,
|
||||
body: JSON.stringify({ on: 'fleet' })
|
||||
});
|
||||
const box = ((await boxRes.json()) as any).data;
|
||||
|
||||
const steamAppId = 980000 + createdGameIds.length;
|
||||
const [game] = await Game.upsert({
|
||||
id: Identifier.ascending('game'),
|
||||
steamAppId,
|
||||
slug: `trial-${steamAppId}`,
|
||||
name: `Trial ${steamAppId}`
|
||||
});
|
||||
createdGameIds.push(game!.id);
|
||||
await Library.upsert({
|
||||
id: Identifier.ascending('userLibrary'),
|
||||
userId: owner.userId,
|
||||
gameId: game!.id,
|
||||
playtime2w: null,
|
||||
playtimeForever: null,
|
||||
lastPlayed: null
|
||||
});
|
||||
|
||||
const machine = fleet.machines[0]!;
|
||||
return {
|
||||
owner,
|
||||
box,
|
||||
gameId: game!.id,
|
||||
user,
|
||||
host: {
|
||||
'x-nestri-machine-id': machine.id,
|
||||
'x-nestri-machine-secret': machine.secret
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function play(s: Awaited<ReturnType<typeof scene>>) {
|
||||
const res = await app.request('/session', {
|
||||
method: 'POST',
|
||||
headers: s.user,
|
||||
body: JSON.stringify({
|
||||
boxId: s.box.id,
|
||||
gameId: s.gameId,
|
||||
linkedAccountId: s.owner.linkedAccountId
|
||||
})
|
||||
});
|
||||
return { res, body: (await res.json()) as any };
|
||||
}
|
||||
|
||||
/** A past run of this box, live from `start` for `minutes`, as the trial counts it. */
|
||||
async function playedBefore(
|
||||
boxId: string,
|
||||
gameId: string,
|
||||
linkedAccountId: string,
|
||||
start: Date,
|
||||
minutes: number
|
||||
) {
|
||||
const id = Identifier.ascending('session');
|
||||
const stop = new Date(start.getTime() + minutes * 60_000);
|
||||
await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started, time_stopped)
|
||||
values (${id}, ${boxId}, ${gameId}, ${linkedAccountId}, 'ended', true, ${start}, ${stop})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
describe('The weekend window', () => {
|
||||
test('Saturday 00:00 to Tuesday 00:00 on the trial’s clock', () => {
|
||||
const closed = Trial.window(WEDNESDAY);
|
||||
expect(closed.open).toBe(false);
|
||||
// Summer time: midnight there is 21:00 UTC the day before.
|
||||
expect(closed.start.toISOString()).toBe('2026-10-09T21:00:00.000Z');
|
||||
expect(closed.end.toISOString()).toBe('2026-10-12T21:00:00.000Z');
|
||||
|
||||
const open = Trial.window(SATURDAY);
|
||||
expect(open.open).toBe(true);
|
||||
expect(open.start.toISOString()).toBe(closed.start.toISOString());
|
||||
});
|
||||
|
||||
test('a weekend with a clock change in it is still three calendar days', () => {
|
||||
// Summer time ends in the early hours of Sunday 25 October 2026.
|
||||
const w = Trial.window(new Date('2026-10-24T12:00:00Z'));
|
||||
expect(w.start.toISOString()).toBe('2026-10-23T21:00:00.000Z');
|
||||
expect(w.end.toISOString()).toBe('2026-10-26T22:00:00.000Z');
|
||||
});
|
||||
|
||||
test('Monday is in; Friday is not', () => {
|
||||
expect(Trial.window(new Date('2026-10-12T12:00:00Z')).open).toBe(true);
|
||||
expect(Trial.window(new Date('2026-10-09T12:00:00Z')).open).toBe(false);
|
||||
});
|
||||
|
||||
test('the last minute of Monday is in; Tuesday 00:00 is out', () => {
|
||||
expect(Trial.window(new Date('2026-10-12T20:59:00Z')).open).toBe(true);
|
||||
expect(Trial.window(new Date('2026-10-12T21:00:00Z')).open).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /session on rented GPUs without a plan', () => {
|
||||
test('in the window: a trial run, and the claim is made', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-ok');
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(201);
|
||||
expect(body.data.trial).toBe(true);
|
||||
expect(body.billing).toBeNull();
|
||||
const claims =
|
||||
await sql`select email, steam_id from trial_claim where team_id = ${s.owner.teamId}`;
|
||||
expect(claims.length).toBe(1);
|
||||
});
|
||||
|
||||
test('outside the window: 429, naming when it opens', async () => {
|
||||
at(WEDNESDAY);
|
||||
const s = await scene('trial-closed');
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(429);
|
||||
expect(body.message).toContain('Saturday to Monday');
|
||||
});
|
||||
|
||||
test('two hours used this weekend: 429', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-spent');
|
||||
await playedBefore(
|
||||
s.box.id,
|
||||
s.gameId,
|
||||
s.owner.linkedAccountId,
|
||||
new Date('2026-10-10T08:00:00Z'),
|
||||
120
|
||||
);
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(429);
|
||||
expect(body.message).toContain('2 free hours');
|
||||
});
|
||||
|
||||
test('time from last weekend does not count', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-last-week');
|
||||
await playedBefore(
|
||||
s.box.id,
|
||||
s.gameId,
|
||||
s.owner.linkedAccountId,
|
||||
new Date('2026-10-03T10:00:00Z'),
|
||||
180
|
||||
);
|
||||
expect((await play(s)).res.status).toBe(201);
|
||||
});
|
||||
|
||||
test('an address that already had the trial: 403', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-email');
|
||||
const [person] = await sql`select email from "user" where id = ${s.owner.userId}`;
|
||||
await sql`insert into trial_claim (id, email, steam_id)
|
||||
values (${Identifier.ascending('trialClaim')}, ${person!.email}, ${'old-' + s.owner.userId})`;
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.message).toContain('already had the free weekend');
|
||||
});
|
||||
|
||||
test('a Steam account that already had the trial: 403', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-steam');
|
||||
const [linked] =
|
||||
await sql`select provider_account_id from linked_account where id = ${s.owner.linkedAccountId}`;
|
||||
await sql`insert into trial_claim (id, email, steam_id)
|
||||
values (${Identifier.ascending('trialClaim')}, ${'gone@trial.example.test'}, ${linked!.provider_account_id})`;
|
||||
expect((await play(s)).res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('five paying teams close it for everyone', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('trial-full');
|
||||
const payers: string[] = [];
|
||||
for (let i = 0; i < Trial.PAID_SEATS; i++) {
|
||||
const p = await Fixtures.owner(`payer-${i}`);
|
||||
createdUserIds.push(p.userId);
|
||||
payers.push(p.teamId);
|
||||
}
|
||||
await sql`update team set plan = 'paid' where id = any(${payers})`;
|
||||
try {
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(403);
|
||||
expect(body.message).toContain('has closed');
|
||||
} finally {
|
||||
await sql`update team set plan = 'free' where id = any(${payers})`;
|
||||
}
|
||||
});
|
||||
|
||||
test('a team with a plan is not on the trial', async () => {
|
||||
at(WEDNESDAY);
|
||||
const s = await scene('trial-paid');
|
||||
await sql`update team set plan = 'paid' where id = ${s.owner.teamId}`;
|
||||
try {
|
||||
const { res, body } = await play(s);
|
||||
expect(res.status).toBe(201);
|
||||
expect(body.data.trial).toBe(false);
|
||||
expect(body.billing).not.toBeNull();
|
||||
} finally {
|
||||
await sql`update team set plan = 'free' where id = ${s.owner.teamId}`;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Stopping a trial run', () => {
|
||||
async function liveTrialRun(s: Awaited<ReturnType<typeof scene>>, startedAt: Date) {
|
||||
const id = Identifier.ascending('session');
|
||||
await sql`insert into session (id, box_id, game_id, linked_account_id, state, trial, time_started)
|
||||
values (${id}, ${s.box.id}, ${s.gameId}, ${s.owner.linkedAccountId}, 'live', true, ${startedAt})`;
|
||||
return id;
|
||||
}
|
||||
|
||||
async function jobs(s: Awaited<ReturnType<typeof scene>>) {
|
||||
const res = await app.request('/machine/jobs', { headers: s.host });
|
||||
return ((await res.json()) as any).data as any[];
|
||||
}
|
||||
|
||||
test('nothing to stop while the hours last', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('stop-none');
|
||||
await liveTrialRun(s, new Date(SATURDAY.getTime() - 60 * 60_000));
|
||||
expect((await jobs(s)).filter((j) => j.kind === 'session.stop')).toEqual([]);
|
||||
});
|
||||
|
||||
test('past two hours: the host is told to stop it, for hours', async () => {
|
||||
at(SATURDAY);
|
||||
const s = await scene('stop-hours');
|
||||
const id = await liveTrialRun(s, new Date(SATURDAY.getTime() - 121 * 60_000));
|
||||
const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop');
|
||||
expect(stops).toEqual([
|
||||
{ kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'hours' }
|
||||
]);
|
||||
});
|
||||
|
||||
test('when the window closes: stopped, for the window', async () => {
|
||||
at(new Date('2026-10-12T21:05:00Z'));
|
||||
const s = await scene('stop-window');
|
||||
const id = await liveTrialRun(s, new Date('2026-10-12T20:30:00Z'));
|
||||
const stops = (await jobs(s)).filter((j) => j.kind === 'session.stop');
|
||||
expect(stops).toEqual([
|
||||
{ kind: 'session.stop', sessionId: id, boxId: s.box.id, reason: 'window' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user