mirror of
https://github.com/nestriness/nestri.git
synced 2026-11-03 22:22:39 +02:00
Merge branch 'feat/fleet-trial' into dev
This commit is contained in:
+78
-25
@@ -1,39 +1,92 @@
|
||||
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 { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||||
import { Examples } from '@nestri/core/examples';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Hono } from 'hono';
|
||||
import { describeRoute } from 'hono-openapi';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { ErrorResponses, notPublic, Result } from '../utils';
|
||||
import { ErrorResponses, notPublic, Result, validator } from '../utils';
|
||||
|
||||
/**
|
||||
* The caller's boxes. Read-only: a box is created by placement and its state
|
||||
* is whatever its host last reported, so the list is the whole of what a
|
||||
* person can ask here for now.
|
||||
* The caller's boxes. A box is created here and placed once — on your own
|
||||
* host, or on the fleet — and its state is whatever its host last reported.
|
||||
*/
|
||||
export namespace BoxApi {
|
||||
export const route = new Hono().use(notPublic).get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Box'],
|
||||
summary: 'Your boxes',
|
||||
description:
|
||||
'Every box you own, oldest first, with its state and — for one that stopped — why, as its host reported it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] })
|
||||
)
|
||||
}
|
||||
export const route = new Hono()
|
||||
.use(notPublic)
|
||||
.post(
|
||||
'/',
|
||||
describeRoute({
|
||||
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.',
|
||||
responses: {
|
||||
201: {
|
||||
content: { 'application/json': { schema: Result(Box.Info) } },
|
||||
description: 'The box, placed'
|
||||
},
|
||||
description: 'Your boxes'
|
||||
},
|
||||
401: ErrorResponses[401]
|
||||
400: ErrorResponses[400],
|
||||
401: ErrorResponses[401],
|
||||
404: ErrorResponses[404],
|
||||
409: ErrorResponses[409]
|
||||
}
|
||||
}),
|
||||
validator(
|
||||
'json',
|
||||
z.object({
|
||||
label: z.string().trim().min(1).max(64).default('box'),
|
||||
tier: z.enum(BoxTier.enumValues).default('sm'),
|
||||
on: z.enum(['own', 'fleet']).default('own')
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { label, tier, on } = c.req.valid('json');
|
||||
const userId = Actor.userID;
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
const box = await Box.createPlaced(
|
||||
{ id: Identifier.ascending('box'), userId, label, tier },
|
||||
on === 'fleet' ? Placement.fleet : Placement.onlyHost
|
||||
);
|
||||
return c.json({ data: box }, 201);
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await Box.listByUser(Actor.userID) })
|
||||
);
|
||||
)
|
||||
.get(
|
||||
'/',
|
||||
describeRoute({
|
||||
tags: ['Box'],
|
||||
summary: 'Your boxes',
|
||||
description:
|
||||
'Every box you own, oldest first, with its state and — for one that stopped — why, as its host reported it.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: Result(
|
||||
z.array(Box.Info).meta({ description: 'Your boxes', example: [Examples.Box] })
|
||||
)
|
||||
}
|
||||
},
|
||||
description: 'Your boxes'
|
||||
},
|
||||
401: ErrorResponses[401]
|
||||
}
|
||||
}),
|
||||
async (c) => c.json({ data: await Box.listByUser(Actor.userID) })
|
||||
);
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ export namespace MachineApi {
|
||||
tags: ['Machine'],
|
||||
summary: 'Issue an install token',
|
||||
description:
|
||||
'Mint a one-time token that registers one host to a team when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
|
||||
'Mint a one-time token that registers one host to a team — or, with `organisationId`, to your organisation’s fleet — when the installer presents it. It expires after an hour and is spent by its first use, because it travels in a command a person pastes and so ends up in shell history.',
|
||||
responses: {
|
||||
200: {
|
||||
content: {
|
||||
@@ -201,11 +201,15 @@ export namespace MachineApi {
|
||||
z.object({
|
||||
teamId: z.string().optional().meta({
|
||||
description: 'Team the host will belong to. Defaults to the caller\u2019s personal team'
|
||||
}),
|
||||
organisationId: z.string().optional().meta({
|
||||
description:
|
||||
'Register the host to this organisation’s fleet instead of to a team. You must belong to the organisation'
|
||||
})
|
||||
})
|
||||
),
|
||||
async (c) => {
|
||||
const { teamId } = c.req.valid('json');
|
||||
const { teamId, organisationId } = c.req.valid('json');
|
||||
const actor = Actor.use();
|
||||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||||
throw new VisibleError(
|
||||
@@ -215,6 +219,30 @@ export namespace MachineApi {
|
||||
);
|
||||
}
|
||||
|
||||
if (organisationId) {
|
||||
if (teamId) {
|
||||
throw new VisibleError(
|
||||
'validation',
|
||||
ErrorCodes.Validation.INVALID_PARAMETER,
|
||||
'A host belongs to a team or to an organisation, not both',
|
||||
'organisationId'
|
||||
);
|
||||
}
|
||||
// Fleet hardware is rented to strangers, so only the
|
||||
// organisation's own people may add to it.
|
||||
if (!(await Organisation.isMember(Actor.userID, organisationId))) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.FORBIDDEN,
|
||||
'You do not belong to that organisation'
|
||||
);
|
||||
}
|
||||
const issued = await InstallToken.create({ organisationId, userId: Actor.userID });
|
||||
return c.json({
|
||||
data: { token: issued.token, expiresAt: issued.expiresAt.toISOString() }
|
||||
});
|
||||
}
|
||||
|
||||
// Same resolution and the same membership rule as `/register`: a
|
||||
// token is a deferred registration, so it may not reach a team the
|
||||
// caller could not register into directly.
|
||||
|
||||
@@ -6,6 +6,8 @@ import { Examples } from '@nestri/core/examples';
|
||||
import { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Session } from '@nestri/core/session/index';
|
||||
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';
|
||||
@@ -236,20 +238,31 @@ export namespace SessionApi {
|
||||
// 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.
|
||||
const payer = await Billing.teamForBox(box.id);
|
||||
const billing = payer
|
||||
? await Billing.assertMayStart({
|
||||
teamId: payer.teamId,
|
||||
nextTier: payer.tier,
|
||||
nextHostClass: payer.hostClass
|
||||
})
|
||||
: null;
|
||||
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: payer.tier,
|
||||
nextHostClass: payer.hostClass
|
||||
})
|
||||
: null;
|
||||
|
||||
const session = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game.id,
|
||||
linkedAccountId
|
||||
linkedAccountId,
|
||||
trial
|
||||
});
|
||||
return c.json({ data: session, billing }, 201);
|
||||
}
|
||||
@@ -410,11 +423,11 @@ export namespace SessionApi {
|
||||
tags: ['Session'],
|
||||
summary: 'Ask for work',
|
||||
description:
|
||||
'Returns the runs waiting to be started on the calling host, and only those — the host comes from its own credentials and the scope is the query, so a box cannot see work for another. Poll at the cadence the heartbeat hands down. Each job carries its kind, so a second kind of work is an addition rather than a change of shape.',
|
||||
'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 the terms it started under ran out, repeated on every poll until the host reports it ended. Poll at the cadence the heartbeat hands down. An agent skips a kind it does not know.',
|
||||
responses: {
|
||||
200: {
|
||||
content: { 'application/json': { schema: Result(z.array(Session.Job)) } },
|
||||
description: 'Work waiting for this host, oldest first'
|
||||
description: 'Work for this host: runs to end first, then runs to start, oldest first'
|
||||
},
|
||||
403: ErrorResponses[403]
|
||||
}
|
||||
|
||||
@@ -0,0 +1,282 @@
|
||||
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 { Game } from '@nestri/core/game/index';
|
||||
import { Identifier } from '@nestri/core/id';
|
||||
import { Machine } from '@nestri/core/machine/index';
|
||||
import { Session } from '@nestri/core/session/index';
|
||||
|
||||
import { app } from '../app/index';
|
||||
import { TEST_FRONTEND_URL } from './setup';
|
||||
|
||||
const sql = testDb();
|
||||
const createdUserIds: string[] = [];
|
||||
const createdOrgIds: string[] = [];
|
||||
const createdGameIds: string[] = [];
|
||||
|
||||
function useFleet(organisationId?: string) {
|
||||
Env.init({
|
||||
NODE_ENV: 'test',
|
||||
FRONTEND_URL: TEST_FRONTEND_URL,
|
||||
...(organisationId ? { FLEET_ORGANISATION_ID: organisationId } : {})
|
||||
});
|
||||
}
|
||||
|
||||
async function person(label: string) {
|
||||
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
|
||||
});
|
||||
return {
|
||||
owner,
|
||||
headers: {
|
||||
authorization: `Bearer ${pat.token}`,
|
||||
'content-type': 'application/json'
|
||||
} as Record<string, string>
|
||||
};
|
||||
}
|
||||
|
||||
async function fleet(hosts: number) {
|
||||
const operator = await Fixtures.owner('operator');
|
||||
createdUserIds.push(operator.userId);
|
||||
const f = await Fixtures.fleet(operator, hosts);
|
||||
createdOrgIds.push(f.organisationId);
|
||||
return f;
|
||||
}
|
||||
|
||||
function createBox(headers: Record<string, string>, body: object) {
|
||||
return app.request('/box', { method: 'POST', headers, body: JSON.stringify(body) });
|
||||
}
|
||||
|
||||
afterAll(async () => {
|
||||
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 game where id = any(${createdGameIds})`;
|
||||
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('POST /box on the fleet', () => {
|
||||
test('places on the online fleet machine holding the fewest boxes', async () => {
|
||||
const f = await fleet(2);
|
||||
useFleet(f.organisationId);
|
||||
const [first, second] = f.machines;
|
||||
|
||||
const a = await person('fleet-a');
|
||||
const resA = await createBox(a.headers, { on: 'fleet' });
|
||||
expect(resA.status).toBe(201);
|
||||
const boxA = ((await resA.json()) as any).data;
|
||||
expect(boxA.machineId).toBe(first!.id);
|
||||
|
||||
const b = await person('fleet-b');
|
||||
const boxB = ((await (await createBox(b.headers, { on: 'fleet' })).json()) as any).data;
|
||||
expect(boxB.machineId).toBe(second!.id);
|
||||
});
|
||||
|
||||
test('skips a fleet machine that is offline', async () => {
|
||||
const f = await fleet(2);
|
||||
useFleet(f.organisationId);
|
||||
await sql`update machine set last_seen = now() - interval '1 hour' where id = ${f.machines[0]!.id}`;
|
||||
|
||||
const p = await person('fleet-offline');
|
||||
const box = ((await (await createBox(p.headers, { on: 'fleet' })).json()) as any).data;
|
||||
expect(box.machineId).toBe(f.machines[1]!.id);
|
||||
});
|
||||
|
||||
test('no fleet machine online is a 429 that says so', async () => {
|
||||
const f = await fleet(1);
|
||||
useFleet(f.organisationId);
|
||||
await sql`update machine set last_seen = null where id = ${f.machines[0]!.id}`;
|
||||
|
||||
const p = await person('fleet-none');
|
||||
const res = await createBox(p.headers, { on: 'fleet' });
|
||||
expect(res.status).toBe(429);
|
||||
expect(((await res.json()) as any).message).toContain('No Nestri GPU is free');
|
||||
});
|
||||
|
||||
test('a deployment with no fleet configured refuses fleet boxes', async () => {
|
||||
useFleet();
|
||||
const p = await person('fleet-unset');
|
||||
const res = await createBox(p.headers, { on: 'fleet' });
|
||||
expect(res.status).toBe(500);
|
||||
expect(((await res.json()) as any).message).toContain('not available');
|
||||
});
|
||||
|
||||
test('one fleet box each: asking again is a 409', 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((await Box.listByUser(p.owner.userId)).length).toBe(1);
|
||||
});
|
||||
|
||||
test('`on` defaults to your own host', async () => {
|
||||
const f = await fleet(1);
|
||||
useFleet(f.organisationId);
|
||||
const p = await person('own-default');
|
||||
const machineId = await Fixtures.machine(p.owner);
|
||||
const res = await createBox(p.headers, { label: 'mine' });
|
||||
expect(res.status).toBe(201);
|
||||
expect(((await res.json()) as any).data.machineId).toBe(machineId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Entitlement on a fleet machine', () => {
|
||||
test('open during your own run on it, and closed before and after', async () => {
|
||||
const f = await fleet(1);
|
||||
useFleet(f.organisationId);
|
||||
const machineId = f.machines[0]!.id;
|
||||
const p = await person('entitle-run');
|
||||
const other = await person('entitle-other');
|
||||
const box = ((await (await createBox(p.headers, { on: 'fleet' })).json()) as any).data;
|
||||
|
||||
const may = (userId: string) => Machine.entitlement({ machineId, userId });
|
||||
expect(await may(p.owner.userId)).toEqual({ entitled: false, reason: 'fleet' });
|
||||
|
||||
const [game] = await Game.upsert({
|
||||
id: Identifier.ascending('game'),
|
||||
steamAppId: 990001,
|
||||
slug: 'fleet-entitle',
|
||||
name: 'Fleet Entitle'
|
||||
});
|
||||
createdGameIds.push(game!.id);
|
||||
const run = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game!.id,
|
||||
linkedAccountId: p.owner.linkedAccountId
|
||||
});
|
||||
await sql`update session set state = 'live' where id = ${run.id}`;
|
||||
|
||||
expect(await may(p.owner.userId)).toEqual({ entitled: true, reason: 'fleet' });
|
||||
// Somebody else's run on the same machine grants you nothing.
|
||||
expect((await may(other.owner.userId)).entitled).toBe(false);
|
||||
|
||||
await sql`update session set state = 'ended', time_stopped = now() where id = ${run.id}`;
|
||||
expect((await may(p.owner.userId)).entitled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Billing a run on the fleet', () => {
|
||||
test('a live fleet run accrues against the box owner’s personal team', async () => {
|
||||
const f = await fleet(1);
|
||||
useFleet(f.organisationId);
|
||||
const machineId = f.machines[0]!.id;
|
||||
const p = await person('bill-fleet');
|
||||
const box = ((await (await createBox(p.headers, { on: 'fleet' })).json()) as any).data;
|
||||
|
||||
const [game] = await Game.upsert({
|
||||
id: Identifier.ascending('game'),
|
||||
steamAppId: 990002,
|
||||
slug: 'fleet-bill',
|
||||
name: 'Fleet Bill'
|
||||
});
|
||||
createdGameIds.push(game!.id);
|
||||
const run = await Session.request({
|
||||
id: Identifier.ascending('session'),
|
||||
boxId: box.id,
|
||||
gameId: game!.id,
|
||||
linkedAccountId: p.owner.linkedAccountId
|
||||
});
|
||||
const claimToken = 'fleet-bill-claim-token-0001';
|
||||
for (const state of ['starting', 'live'] as const) {
|
||||
const r = await Session.transition({ id: run.id, machineId, state, claimToken });
|
||||
expect(r.outcome).toBe('moved');
|
||||
}
|
||||
|
||||
const open = await sql`select team_id, ended_at from burn_segment where session_id = ${run.id}`;
|
||||
expect(open.length).toBe(1);
|
||||
expect(open[0]!.team_id).toBe(p.owner.teamId);
|
||||
expect(open[0]!.ended_at).toBeNull();
|
||||
|
||||
await Session.transition({ id: run.id, machineId, state: 'ended', claimToken });
|
||||
const closed = await sql`select ended_at from burn_segment where session_id = ${run.id}`;
|
||||
expect(closed[0]!.ended_at).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('An install token for the fleet', () => {
|
||||
async function member(domain: string, organisationId: string) {
|
||||
const userId = Identifier.ascending('user');
|
||||
await sql`insert into "user" (id, name, email, email_verified) values (${userId}, 'staff', ${'staff@' + domain}, true)`;
|
||||
createdUserIds.push(userId);
|
||||
const pat = await AccessToken.create({
|
||||
id: Identifier.ascending('accessToken'),
|
||||
ownerUserId: userId,
|
||||
teamId: null,
|
||||
name: 'staff'
|
||||
});
|
||||
return {
|
||||
userId,
|
||||
organisationId,
|
||||
headers: { authorization: `Bearer ${pat.token}`, 'content-type': 'application/json' }
|
||||
};
|
||||
}
|
||||
|
||||
async function org() {
|
||||
const id = Identifier.ascending('organisation');
|
||||
const domain = `${id.slice(-10).toLowerCase()}.example.test`;
|
||||
await sql`insert into organisation (id, name, slug, domain, domain_verified)
|
||||
values (${id}, 'Fleet', ${'fleet-' + id.slice(-10).toLowerCase()}, ${domain}, true)`;
|
||||
createdOrgIds.push(id);
|
||||
return { id, domain };
|
||||
}
|
||||
|
||||
test('a member mints one, and the host it registers belongs to the fleet', async () => {
|
||||
const o = await org();
|
||||
const staff = await member(o.domain, o.id);
|
||||
const minted = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: staff.headers,
|
||||
body: JSON.stringify({ organisationId: o.id })
|
||||
});
|
||||
expect(minted.status).toBe(200);
|
||||
const { token } = ((await minted.json()) as any).data;
|
||||
|
||||
const installed = await app.request('/machine/install', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ token, label: 'fleet-host' })
|
||||
});
|
||||
expect(installed.status).toBe(200);
|
||||
const { machineId } = ((await installed.json()) as any).data;
|
||||
const machine = await Machine.fromID(machineId);
|
||||
expect(machine?.organisationId).toBe(o.id);
|
||||
expect(machine?.teamId).toBeNull();
|
||||
});
|
||||
|
||||
test('somebody outside the organisation is refused', async () => {
|
||||
const o = await org();
|
||||
const outsider = await person('fleet-outsider');
|
||||
const res = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: outsider.headers,
|
||||
body: JSON.stringify({ organisationId: o.id })
|
||||
});
|
||||
expect(res.status).toBe(403);
|
||||
});
|
||||
|
||||
test('naming a team and an organisation is a 400', async () => {
|
||||
const o = await org();
|
||||
const staff = await member(o.domain, o.id);
|
||||
const res = await app.request('/machine/install-token', {
|
||||
method: 'POST',
|
||||
headers: staff.headers,
|
||||
body: JSON.stringify({ organisationId: o.id, teamId: 'tem_whatever' })
|
||||
});
|
||||
expect(res.status).toBe(400);
|
||||
});
|
||||
});
|
||||
@@ -161,6 +161,7 @@ describe('POST /session', () => {
|
||||
ticket: null,
|
||||
timeStarted: null,
|
||||
timeStopped: null,
|
||||
trial: false,
|
||||
errorMessage: null
|
||||
});
|
||||
expect(body.data.id.startsWith('ses_')).toBe(true);
|
||||
@@ -724,7 +725,8 @@ describe('POST /session/:id/ticket', () => {
|
||||
'state',
|
||||
'ticket',
|
||||
'timeStarted',
|
||||
'timeStopped'
|
||||
'timeStopped',
|
||||
'trial'
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,297 @@
|
||||
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('Friday 00:00 to Monday 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-08T21:00:00.000Z');
|
||||
expect(closed.end.toISOString()).toBe('2026-10-11T21: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-22T21:00:00.000Z');
|
||||
expect(w.end.toISOString()).toBe('2026-10-25T22:00:00.000Z');
|
||||
});
|
||||
|
||||
test('the last minute of Sunday is in; Monday 00:00 is out', () => {
|
||||
expect(Trial.window(new Date('2026-10-11T20:59:00Z')).open).toBe(true);
|
||||
expect(Trial.window(new Date('2026-10-11T21: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('Friday to Sunday');
|
||||
});
|
||||
|
||||
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-09T10: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-11T21:05:00Z'));
|
||||
const s = await scene('stop-window');
|
||||
const id = await liveTrialRun(s, new Date('2026-10-11T20: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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,18 @@
|
||||
CREATE TABLE "trial_claim" (
|
||||
"id" char(30) PRIMARY KEY NOT NULL,
|
||||
"time_created" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_updated" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"time_deleted" timestamp with time zone,
|
||||
"team_id" char(30),
|
||||
"user_id" char(30),
|
||||
"email" text NOT NULL,
|
||||
"steam_id" text NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "session" ADD COLUMN "trial" boolean DEFAULT false NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "trial_claim" ADD CONSTRAINT "trial_claim_team_id_team_id_fk" FOREIGN KEY ("team_id") REFERENCES "public"."team"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "trial_claim" ADD CONSTRAINT "trial_claim_user_id_user_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_email_unique" ON "trial_claim" USING btree ("email");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_steam_unique" ON "trial_claim" USING btree ("steam_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "trial_claim_team_unique" ON "trial_claim" USING btree ("team_id");--> statement-breakpoint
|
||||
CREATE INDEX "trial_claim_user_idx" ON "trial_claim" USING btree ("user_id");
|
||||
@@ -0,0 +1,4 @@
|
||||
ALTER TABLE "install_token" ALTER COLUMN "team_id" DROP NOT NULL;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD COLUMN "organisation_id" char(30);--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_organisation_id_organisation_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisation"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "install_token" ADD CONSTRAINT "install_token_one_owner" CHECK (("install_token"."team_id" is null) != ("install_token"."organisation_id" is null));
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -127,6 +127,20 @@
|
||||
"when": 1790573670492,
|
||||
"tag": "0017_install_token",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 18,
|
||||
"version": "7",
|
||||
"when": 1791354383696,
|
||||
"tag": "0018_free_weekend_trial",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 19,
|
||||
"version": "7",
|
||||
"when": 1791355334692,
|
||||
"tag": "0019_install_token_for_a_fleet",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,10 +46,11 @@ export namespace Billing {
|
||||
/**
|
||||
* The team that pays for a box.
|
||||
*
|
||||
* A box runs on a host, and the host says who owns it. Fleet hardware
|
||||
* belongs to an organisation, which is not a billing subject — nothing is
|
||||
* placed there yet, and when it is, what grants it is a plan rather than
|
||||
* this lookup.
|
||||
* On a person's own hardware, the team the host is registered under. On
|
||||
* the fleet, the hardware belongs to an organisation, which is not a
|
||||
* billing subject — so the run is the box owner's, and is billed to their
|
||||
* personal team, the one every account has and the one a plan is bought
|
||||
* on.
|
||||
*/
|
||||
export const teamForBox = fn(z.string(), async (boxId) => {
|
||||
const box = await Box.fromID(boxId);
|
||||
@@ -57,11 +58,14 @@ export namespace Billing {
|
||||
return null;
|
||||
}
|
||||
const machine = await Machine.fromID(box.machineId);
|
||||
if (!machine?.teamId) {
|
||||
const teamId =
|
||||
machine?.teamId ??
|
||||
(machine?.organisationId ? ((await Team.personalFor(box.userId))?.id ?? null) : null);
|
||||
if (!machine || !teamId) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
teamId: machine.teamId,
|
||||
teamId,
|
||||
tier: box.tier as Burn.Tier,
|
||||
// Whose hardware decides the cost basis, and the machine is the only
|
||||
// thing that knows. A host an organisation owns is ours to pay for;
|
||||
|
||||
@@ -4,6 +4,7 @@ import z from 'zod';
|
||||
import { Database } from '../db/index.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { BoxState, BoxTable, BoxTier } from './box.sql.js';
|
||||
import { Placement } from './placement.js';
|
||||
|
||||
@@ -125,6 +126,33 @@ export namespace Box {
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The box a person already holds on the fleet's hardware, if any.
|
||||
*
|
||||
* One each: a rented GPU is the scarce thing, and a second box would be a
|
||||
* second claim on it that nothing yet prices.
|
||||
*/
|
||||
export const onFleet = fn(
|
||||
z.object({ userId: Info.shape.userId, organisationId: z.string() }),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
.select({ box: BoxTable })
|
||||
.from(BoxTable)
|
||||
.innerJoin(MachineTable, eq(BoxTable.machineId, MachineTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.userId, input.userId),
|
||||
eq(MachineTable.organisationId, input.organisationId),
|
||||
isNull(BoxTable.timeDeleted),
|
||||
isNull(MachineTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.then((rows) => (rows[0] ? serialize(rows[0].box) : null));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
export const listByMachine = fn(Info.shape.machineId, async (machineId) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { and, count, inArray, isNull } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Database } from '../db/index.js';
|
||||
import { Env } from '../env.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { BoxTier } from './box.sql.js';
|
||||
import { BoxTable, BoxTier } from './box.sql.js';
|
||||
|
||||
/**
|
||||
* Deciding which host a box runs on.
|
||||
@@ -66,6 +69,57 @@ export namespace Placement {
|
||||
return hosts[0]!.id;
|
||||
};
|
||||
|
||||
/**
|
||||
* Place it on the fleet: a machine the fleet organisation owns.
|
||||
*
|
||||
* Only machines that are online — a box placed on one that is not would be a
|
||||
* session that never starts — and, among those, the one holding the fewest
|
||||
* boxes. That is a spread, not a scheduler: it knows nothing about how big a
|
||||
* box is or whether it is running. It is the smallest policy that does not
|
||||
* pile every customer onto the oldest row, and this file is still the one
|
||||
* place a real scheduler replaces. todo(d-0014)
|
||||
*/
|
||||
export const fleet: Placer = async () => {
|
||||
const organisationId = Env.get().FLEET_ORGANISATION_ID;
|
||||
if (!organisationId) {
|
||||
throw new VisibleError(
|
||||
'internal',
|
||||
ErrorCodes.Server.SERVICE_UNAVAILABLE,
|
||||
'Nestri GPUs are not available on this deployment'
|
||||
);
|
||||
}
|
||||
const online = (await Machine.listByOrganisation(organisationId)).filter((m) =>
|
||||
Machine.isOnline(m.lastSeen ?? null)
|
||||
);
|
||||
if (online.length === 0) {
|
||||
// Capacity, not a fault: the same request succeeds once a machine is
|
||||
// back, which is what a 429 tells a caller.
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.Server.SERVICE_UNAVAILABLE,
|
||||
'No Nestri GPU is free right now. Try again in a few minutes'
|
||||
);
|
||||
}
|
||||
const held = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ machineId: BoxTable.machineId, boxes: count() })
|
||||
.from(BoxTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(
|
||||
BoxTable.machineId,
|
||||
online.map((m) => m.id)
|
||||
),
|
||||
isNull(BoxTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.groupBy(BoxTable.machineId)
|
||||
);
|
||||
const boxes = new Map(held.map((h) => [h.machineId, h.boxes]));
|
||||
// Stable: on a tie, the order `listByOrganisation` gives, oldest first.
|
||||
return [...online].sort((a, b) => (boxes.get(a.id) ?? 0) - (boxes.get(b.id) ?? 0))[0]!.id;
|
||||
};
|
||||
|
||||
/** Place a box, using `onlyHost` unless a caller supplies its own placer. */
|
||||
export async function choose(request: Request, placer: Placer = onlyHost): Promise<string> {
|
||||
return placer(Request.parse(request));
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Actor } from '../actor.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { Organisation } from '../organisation/index.js';
|
||||
import { Team } from '../team/index.js';
|
||||
import { User } from '../user/index.js';
|
||||
import { LinkedAccount } from '../user/linked-account.js';
|
||||
@@ -72,4 +73,34 @@ export namespace Fixtures {
|
||||
});
|
||||
return registered.id;
|
||||
}
|
||||
|
||||
/**
|
||||
* A fleet: an organisation and `hosts` machines it owns outright, all online.
|
||||
*
|
||||
* A fleet machine still names a person who registered it (`ownerUserId` is
|
||||
* not nullable), so `operator` is that person — the one who plugged it in,
|
||||
* not the one who pays for it.
|
||||
*/
|
||||
export async function fleet(operator: Owner, hosts = 1) {
|
||||
const organisationId = Identifier.ascending('organisation');
|
||||
await Organisation.create({
|
||||
id: organisationId,
|
||||
name: 'Fleet',
|
||||
slug: `fleet-${organisationId.slice(-10).toLowerCase()}`,
|
||||
domain: `${organisationId.slice(-10).toLowerCase()}.example.test`
|
||||
});
|
||||
const machines: { id: string; secret: string }[] = [];
|
||||
for (let i = 0; i < hosts; i++) {
|
||||
const registered = await Machine.register({
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: operator.userId,
|
||||
teamId: null,
|
||||
organisationId,
|
||||
label: `fleet-${i}`
|
||||
});
|
||||
await Machine.touchLastSeen({ id: registered.id });
|
||||
machines.push({ id: registered.id, secret: registered.secret });
|
||||
}
|
||||
return { organisationId, machines };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,7 +63,13 @@ export namespace Env {
|
||||
RELEASES_ACCESS_KEY_ID: z.string().optional(),
|
||||
RELEASES_SECRET_ACCESS_KEY: z.string().optional(),
|
||||
|
||||
DATABASE_URL: z.string().optional()
|
||||
DATABASE_URL: z.string().optional(),
|
||||
/**
|
||||
* The organisation whose machines are rented to customers: the fleet.
|
||||
* Unset, nothing is placed on rented hardware. Configured per deploy,
|
||||
* never written into the repository.
|
||||
*/
|
||||
FLEET_ORGANISATION_ID: z.string().optional()
|
||||
});
|
||||
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
@@ -14,6 +14,7 @@ export namespace Identifier {
|
||||
pairingCode: 'pai',
|
||||
machine: 'mch',
|
||||
installToken: 'mit',
|
||||
trialClaim: 'trc',
|
||||
box: 'box',
|
||||
session: 'ses',
|
||||
accessToken: 'pat',
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
|
||||
import { and, eq, isNull, sql } from 'drizzle-orm';
|
||||
import { and, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { BoxTable } from '../box/box.sql.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { SessionTable } from '../session/session.sql.js';
|
||||
import { Member } from '../team/member.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
import { Slug } from './slug.js';
|
||||
@@ -398,12 +400,12 @@ export namespace Machine {
|
||||
* removing someone from a team takes their box access with it and nobody
|
||||
* has to remember to revoke anything.
|
||||
*
|
||||
* **Fleet hardware refuses everyone for now, and that is deliberate.** What
|
||||
* grants it is a plan, and nothing here can yet ask whether a user has one
|
||||
* — so the honest answer is no rather than a yes that would hand out metered
|
||||
* hardware for free. Failing closed on the expensive case is the cheap
|
||||
* mistake to make; the branch is written out so there is one obvious place
|
||||
* for the plan check to land. todo(d-0051)
|
||||
* **Fleet hardware is open only during a run you asked for.** The plan check
|
||||
* is made once, when the run is requested, and a run that passed it is what
|
||||
* grants access — so this asks for one, starting or live, of a box you own
|
||||
* on this machine. Holding a box is not enough: a box is free to hold, and
|
||||
* the hardware under it is not. When the run ends, so does the access.
|
||||
* ref(d-0051)
|
||||
*/
|
||||
export const entitlement = fn(
|
||||
z.object({ machineId: z.string(), userId: z.string() }),
|
||||
@@ -415,7 +417,23 @@ export namespace Machine {
|
||||
if (machine.organisationId) {
|
||||
// Fleet hardware. Not the owner's and not a team's, so neither
|
||||
// test below means anything here.
|
||||
return { entitled: false, reason: 'fleet' };
|
||||
const run = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: SessionTable.id })
|
||||
.from(SessionTable)
|
||||
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.machineId, machine.id),
|
||||
eq(BoxTable.userId, input.userId),
|
||||
inArray(SessionTable.state, ['starting', 'live']),
|
||||
isNull(SessionTable.timeDeleted),
|
||||
isNull(BoxTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
.limit(1)
|
||||
);
|
||||
return { entitled: run.length > 0, reason: 'fleet' };
|
||||
}
|
||||
if (machine.ownerUserId && machine.ownerUserId === input.userId) {
|
||||
return { entitled: true, reason: 'owner' };
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { check, index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
import { OrganisationTable } from '../organisation/organisation.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { MachineTable } from './machine.sql.js';
|
||||
|
||||
/**
|
||||
* A one-time credential that registers exactly one machine to one team.
|
||||
* A one-time credential that registers exactly one machine to one team, or to
|
||||
* one organisation's fleet — never both, by the same rule a machine has.
|
||||
*
|
||||
* It exists so that installing on a host is a command a person pastes, rather
|
||||
* than a user session copied onto a machine. It travels in that command, so it
|
||||
@@ -18,9 +21,10 @@ export const InstallTokenTable = pgTable(
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id')
|
||||
.notNull()
|
||||
.references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'cascade' }),
|
||||
organisationId: ulid('organisation_id').references(() => OrganisationTable.id, {
|
||||
onDelete: 'cascade'
|
||||
}),
|
||||
// Who asked for it. They become the machine's owner, as they would have
|
||||
// by registering it with their own session.
|
||||
createdByUserId: ulid('created_by_user_id')
|
||||
@@ -35,6 +39,7 @@ export const InstallTokenTable = pgTable(
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('install_token_hash_unique').on(t.tokenHash),
|
||||
index('install_token_team_idx').on(t.teamId)
|
||||
index('install_token_team_idx').on(t.teamId),
|
||||
check('install_token_one_owner', sql`(${t.teamId} is null) != (${t.organisationId} is null)`)
|
||||
]
|
||||
);
|
||||
|
||||
@@ -30,12 +30,14 @@ describe('Install tokens', () => {
|
||||
const registered = await InstallToken.redeem({ token, label: 'host' });
|
||||
expect(registered?.secret.startsWith('msk_')).toBe(true);
|
||||
|
||||
const rows = await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
|
||||
const rows =
|
||||
await sql`select team_id, owner_user_id from machine where id = ${registered!.machineId}`;
|
||||
expect(rows[0]!.team_id).toBe(owner.teamId);
|
||||
expect(rows[0]!.owner_user_id).toBe(owner.userId);
|
||||
|
||||
// Only the digest is kept, and the row records what it produced.
|
||||
const tok = await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
|
||||
const tok =
|
||||
await sql`select token_hash, machine_id from install_token where team_id = ${owner.teamId}`;
|
||||
expect(tok[0]!.token_hash).not.toBe(token);
|
||||
expect(tok[0]!.machine_id).toBe(registered!.machineId);
|
||||
});
|
||||
|
||||
@@ -31,25 +31,40 @@ export namespace InstallToken {
|
||||
.join('');
|
||||
}
|
||||
|
||||
/** Issue a token for `teamId`. The caller has already checked membership. */
|
||||
export const create = fn(z.object({ teamId: z.string(), userId: z.string() }), async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
teamId: input.teamId,
|
||||
createdByUserId: input.userId,
|
||||
tokenHash,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
return { token, expiresAt };
|
||||
});
|
||||
/**
|
||||
* Issue a token for a team, or for an organisation's fleet. The caller has
|
||||
* already checked membership of whichever it names.
|
||||
*/
|
||||
export const create = fn(
|
||||
z
|
||||
.object({
|
||||
teamId: z.string().optional(),
|
||||
organisationId: z.string().optional(),
|
||||
userId: z.string()
|
||||
})
|
||||
.refine((v) => !v.teamId !== !v.organisationId, {
|
||||
message: 'A token is for a team or for an organisation, and not both'
|
||||
}),
|
||||
async (input) => {
|
||||
const token = generate();
|
||||
const tokenHash = await digest(token);
|
||||
const expiresAt = await Database.use(async (tx) =>
|
||||
tx
|
||||
.insert(InstallTokenTable)
|
||||
.values({
|
||||
id: Identifier.ascending('installToken'),
|
||||
teamId: input.teamId ?? null,
|
||||
organisationId: input.organisationId ?? null,
|
||||
createdByUserId: input.userId,
|
||||
tokenHash,
|
||||
expiresAt: sql`now() + interval '${sql.raw(String(TTL_MINUTES))} minutes'`
|
||||
})
|
||||
.returning({ expiresAt: InstallTokenTable.expiresAt })
|
||||
.then((rows) => rows[0]!.expiresAt)
|
||||
);
|
||||
return { token, expiresAt };
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Spend a token and register the machine it was issued for.
|
||||
@@ -81,6 +96,7 @@ export namespace InstallToken {
|
||||
.returning({
|
||||
id: InstallTokenTable.id,
|
||||
teamId: InstallTokenTable.teamId,
|
||||
organisationId: InstallTokenTable.organisationId,
|
||||
userId: InstallTokenTable.createdByUserId
|
||||
})
|
||||
.then((rows) => rows.at(0) ?? null)
|
||||
@@ -91,6 +107,7 @@ export namespace InstallToken {
|
||||
id: Identifier.ascending('machine'),
|
||||
ownerUserId: spent.userId,
|
||||
teamId: spent.teamId,
|
||||
organisationId: spent.organisationId,
|
||||
label: input.label
|
||||
});
|
||||
|
||||
|
||||
@@ -194,9 +194,9 @@ describe('Fleet hardware', () => {
|
||||
});
|
||||
|
||||
describe('Entitlement on fleet hardware', () => {
|
||||
test('nobody is entitled yet, and the reason says why', async () => {
|
||||
// What grants a run on metered hardware is a plan, and there is nothing
|
||||
// to ask yet — so this fails closed rather than giving it away.
|
||||
test('nobody without a run on it is entitled, and the reason says why', async () => {
|
||||
// A run that passed the plan check is what grants rented hardware; with
|
||||
// none, this fails closed rather than giving it away.
|
||||
const orgId = await newOrg('org-entitle', 'entitle.example', true);
|
||||
const userId = await newUser('entitle', 'someone@entitle.example', true);
|
||||
const registered = await Machine.register({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { and, desc, eq, inArray, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { Burn } from '../billing/burn.js';
|
||||
import { Billing } from '../billing/index.js';
|
||||
import { BoxTable, BoxTier } from '../box/box.sql.js';
|
||||
import { Box } from '../box/index.js';
|
||||
import { Database } from '../db/index.js';
|
||||
@@ -9,7 +10,7 @@ import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { Examples } from '../examples.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { GameTable } from '../game/game.sql.js';
|
||||
import { Machine } from '../machine/index.js';
|
||||
import { Trial } from '../trial/index.js';
|
||||
import { SessionState, SessionTable } from './session.sql.js';
|
||||
|
||||
/**
|
||||
@@ -66,6 +67,11 @@ export namespace Session {
|
||||
description: 'When this run ended',
|
||||
example: Examples.Session.timeStopped
|
||||
}),
|
||||
trial: z.boolean().optional().meta({
|
||||
description:
|
||||
'Whether this run is on Nestri GPUs under the free weekend rather than a plan. A trial run is stopped when its hours or its window run out',
|
||||
example: false
|
||||
}),
|
||||
errorMessage: z.string().nullable().optional().meta({
|
||||
description: 'Why it failed, when it did',
|
||||
example: Examples.Session.errorMessage
|
||||
@@ -80,7 +86,9 @@ export namespace Session {
|
||||
export type Info = z.infer<typeof Info>;
|
||||
|
||||
export const create = fn(
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }),
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
|
||||
trial: z.boolean().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
return Database.use(async (tx) => {
|
||||
return tx
|
||||
@@ -89,7 +97,8 @@ export namespace Session {
|
||||
id: input.id,
|
||||
boxId: input.boxId,
|
||||
gameId: input.gameId,
|
||||
linkedAccountId: input.linkedAccountId
|
||||
linkedAccountId: input.linkedAccountId,
|
||||
trial: input.trial ?? false
|
||||
})
|
||||
.returning()
|
||||
.then((rows) => serialize(rows[0]!));
|
||||
@@ -119,7 +128,9 @@ export namespace Session {
|
||||
* answered is a timing detail.
|
||||
*/
|
||||
export const request = fn(
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }),
|
||||
Info.pick({ id: true, boxId: true, gameId: true, linkedAccountId: true }).extend({
|
||||
trial: z.boolean().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
try {
|
||||
return await create(input);
|
||||
@@ -296,7 +307,7 @@ export namespace Session {
|
||||
* `kind` is on the wire while there is only one value, so that a second
|
||||
* kind is an addition rather than a redesign of the poll.
|
||||
*/
|
||||
export const Job = z
|
||||
export const StartJob = z
|
||||
.object({
|
||||
kind: z.literal('session.start').meta({
|
||||
description: 'What the agent is being asked to do',
|
||||
@@ -332,6 +343,38 @@ export namespace Session {
|
||||
description: 'One run waiting to be started, as handed to the agent that will start it'
|
||||
});
|
||||
|
||||
/**
|
||||
* A run the agent must end now, because the terms it started under have
|
||||
* run out. The agent ends it as it would end any run and reports `ended`;
|
||||
* the job repeats on every poll until it does.
|
||||
*/
|
||||
export const StopJob = z
|
||||
.object({
|
||||
kind: z.literal('session.stop').meta({
|
||||
description: 'What the agent is being asked to do',
|
||||
example: 'session.stop'
|
||||
}),
|
||||
sessionId: z.string().meta({
|
||||
description: 'The run to end',
|
||||
example: Examples.Session.id
|
||||
}),
|
||||
boxId: z.string().meta({
|
||||
description: 'The box it runs in',
|
||||
example: Examples.Session.boxId
|
||||
}),
|
||||
reason: z.enum(['window', 'hours']).meta({
|
||||
description:
|
||||
'`window`: the free weekend closed. `hours`: this weekend’s free hours are used',
|
||||
example: 'hours'
|
||||
})
|
||||
})
|
||||
.meta({
|
||||
ref: 'SessionStopJob',
|
||||
description: 'One run to end now, as handed to the agent running it'
|
||||
});
|
||||
|
||||
export const Job = z.discriminatedUnion('kind', [StartJob, StopJob]);
|
||||
|
||||
export type Job = z.infer<typeof Job>;
|
||||
|
||||
/**
|
||||
@@ -342,8 +385,11 @@ export namespace Session {
|
||||
* and so what one set of long-lived credentials can see is decided by this
|
||||
* `where` clause rather than by whoever is holding them.
|
||||
*/
|
||||
export const listJobsForMachine = fn(z.string(), async (machineId) => {
|
||||
return Database.use(async (tx) => {
|
||||
export const listJobsForMachine = fn(z.string(), async (machineId): Promise<Job[]> => {
|
||||
const stops = (await Trial.overdue(machineId)).map(
|
||||
(run): Job => ({ kind: 'session.stop', ...run })
|
||||
);
|
||||
const starts = await Database.use(async (tx) => {
|
||||
return tx
|
||||
.select({ session: SessionTable, box: BoxTable, game: GameTable })
|
||||
.from(SessionTable)
|
||||
@@ -364,7 +410,7 @@ export namespace Session {
|
||||
kind: 'session.start',
|
||||
sessionId: row.session.id,
|
||||
boxId: row.box.id,
|
||||
boxTier: row.box.tier as Job['boxTier'],
|
||||
boxTier: row.box.tier as z.infer<typeof StartJob>['boxTier'],
|
||||
gameId: row.game.id,
|
||||
steamAppId: row.game.steamAppId,
|
||||
linkedAccountId: row.session.linkedAccountId
|
||||
@@ -372,6 +418,7 @@ export namespace Session {
|
||||
)
|
||||
);
|
||||
});
|
||||
return [...stops, ...starts];
|
||||
});
|
||||
|
||||
/**
|
||||
@@ -661,25 +708,29 @@ export namespace Session {
|
||||
// `live` is where it starts rather than `starting`, because what
|
||||
// is billed is an envelope actually held — a box that never
|
||||
// finished coming up held nothing.
|
||||
const machine = await Machine.fromID(input.machineId);
|
||||
if (machine?.teamId) {
|
||||
// Who pays is `Billing.teamForBox`'s answer and nobody else's: the
|
||||
// host's team on someone's own hardware, the box owner's personal
|
||||
// team on the fleet.
|
||||
// A trial run is counted by the trial, from the run's own times,
|
||||
// and not against the plan's allowance it is not drawing on.
|
||||
const payer = moved.trial ? null : await Billing.teamForBox(moved.boxId);
|
||||
if (payer) {
|
||||
if (current.state !== 'live' && moved.state === 'live') {
|
||||
// The rate is fixed here, from what this run actually is:
|
||||
// the size it holds, and whose card it holds it on. Both
|
||||
// are settled before the run starts, which is what lets a
|
||||
// person be told the cost before committing to it.
|
||||
const box = await Box.fromID(moved.boxId);
|
||||
await Burn.start({
|
||||
teamId: machine.teamId,
|
||||
teamId: payer.teamId,
|
||||
sessionId: moved.id,
|
||||
tier: (box?.tier ?? 'sm') as Burn.Tier,
|
||||
hostClass: machine.organisationId ? 'fleet' : 'byo'
|
||||
tier: payer.tier,
|
||||
hostClass: payer.hostClass
|
||||
});
|
||||
} else if (
|
||||
ACCRUING.includes(current.state as (typeof ACCRUING)[number]) &&
|
||||
!ACCRUING.includes(moved.state as (typeof ACCRUING)[number])
|
||||
) {
|
||||
await Burn.stop({ teamId: machine.teamId, sessionId: moved.id });
|
||||
await Burn.stop({ teamId: payer.teamId, sessionId: moved.id });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -787,6 +838,7 @@ export namespace Session {
|
||||
ticket: input.ticket,
|
||||
timeStarted: input.timeStarted?.toISOString() ?? null,
|
||||
timeStopped: input.timeStopped?.toISOString() ?? null,
|
||||
trial: input.trial,
|
||||
errorMessage: input.errorMessage
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { sql } from 'drizzle-orm';
|
||||
import { index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
import { boolean, index, pgEnum, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { BoxTable } from '../box/box.sql.js';
|
||||
import { id, timestamps, ulid, utc } from '../db/types.js';
|
||||
@@ -61,6 +61,11 @@ export const SessionTable = pgTable(
|
||||
/** Null until the box actually starts, which is not when the row appears. */
|
||||
timeStarted: utc('time_started'),
|
||||
timeStopped: utc('time_stopped'),
|
||||
// A run on rented GPUs under the free weekend rather than a plan. Its
|
||||
// time is counted against the trial and not the plan's allowance, and
|
||||
// it is the run that is stopped when the trial's hours or its window
|
||||
// run out. Fixed when the run is requested.
|
||||
trial: boolean('trial').notNull().default(false),
|
||||
/** Why it ended badly, when it did. */
|
||||
errorMessage: text('error_message'),
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,303 @@
|
||||
import { and, count, eq, isNull, sql } from 'drizzle-orm';
|
||||
import z from 'zod';
|
||||
|
||||
import { BoxTable } from '../box/box.sql.js';
|
||||
import { Database } from '../db/index.js';
|
||||
import { ErrorCodes, VisibleError } from '../error.js';
|
||||
import { fn } from '../fn.js';
|
||||
import { Identifier } from '../id.js';
|
||||
import { MachineTable } from '../machine/machine.sql.js';
|
||||
import { SessionTable } from '../session/session.sql.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { LinkedAccountTable } from '../user/linked-account.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
import { TrialClaimTable } from './trial.sql.js';
|
||||
|
||||
/**
|
||||
* The free weekend: play on rented GPUs without a plan, a little, at weekends.
|
||||
*
|
||||
* Three rules and nothing else:
|
||||
*
|
||||
* - **When.** Friday 00:00 to Monday 00:00, on the clock of the place the
|
||||
* hardware is run from. Outside that, a trial run is refused, and one still
|
||||
* going when it closes is stopped.
|
||||
* - **How much.** {@link HOURS} of live time per window, across every run.
|
||||
* Unlike a plan's allowance, this one stops a run that reaches it: the
|
||||
* trial is the hardware lent, not a budget being drawn down.
|
||||
* - **For how long.** Until {@link PAID_SEATS} teams pay for a plan. Then the
|
||||
* hardware is theirs and the trial closes for everyone.
|
||||
*
|
||||
* Downloading a game is not playing it and is never refused here; only a run
|
||||
* is.
|
||||
*/
|
||||
export namespace Trial {
|
||||
export const HOURS = 2;
|
||||
export const PAID_SEATS = 5;
|
||||
export const ZONE = 'Europe/Helsinki';
|
||||
|
||||
const SECONDS = HOURS * 3600;
|
||||
|
||||
/**
|
||||
* What time it is, for every rule here. Replaceable so a test can stand on
|
||||
* a Saturday; nothing else has a reason to touch it.
|
||||
*/
|
||||
export let now = () => new Date();
|
||||
export function useClock(clock: () => Date) {
|
||||
now = clock;
|
||||
}
|
||||
|
||||
/** A zone's UTC offset at one instant, in milliseconds. */
|
||||
function offsetMs(at: Date): number {
|
||||
const name = new Intl.DateTimeFormat('en-US', { timeZone: ZONE, timeZoneName: 'longOffset' })
|
||||
.formatToParts(at)
|
||||
.find((p) => p.type === 'timeZoneName')!.value;
|
||||
const m = /GMT([+-])(\d{2}):?(\d{2})?/.exec(name);
|
||||
if (!m) return 0;
|
||||
const sign = m[1] === '-' ? -1 : 1;
|
||||
return sign * (Number(m[2]) * 60 + Number(m[3] ?? 0)) * 60_000;
|
||||
}
|
||||
|
||||
/** Midnight at the start of a calendar day in {@link ZONE}, as an instant. */
|
||||
function midnight(year: number, month: number, day: number): Date {
|
||||
const guess = Date.UTC(year, month - 1, day);
|
||||
// Twice, because the offset at the guess and at the answer can differ by
|
||||
// the hour a clock change moved.
|
||||
const once = guess - offsetMs(new Date(guess));
|
||||
return new Date(guess - offsetMs(new Date(once)));
|
||||
}
|
||||
|
||||
/** The calendar day an instant falls on in {@link ZONE}, and its weekday (Mon 1 … Sun 7). */
|
||||
function localDay(at: Date) {
|
||||
const parts = Object.fromEntries(
|
||||
new Intl.DateTimeFormat('en-US', {
|
||||
timeZone: ZONE,
|
||||
year: 'numeric',
|
||||
month: 'numeric',
|
||||
day: 'numeric',
|
||||
weekday: 'short'
|
||||
})
|
||||
.formatToParts(at)
|
||||
.map((p) => [p.type, p.value])
|
||||
);
|
||||
const weekday = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(parts.weekday!) + 1;
|
||||
return {
|
||||
year: Number(parts.year),
|
||||
month: Number(parts.month),
|
||||
day: Number(parts.day),
|
||||
weekday
|
||||
};
|
||||
}
|
||||
|
||||
function addDays(d: { year: number; month: number; day: number }, n: number) {
|
||||
const t = new Date(Date.UTC(d.year, d.month - 1, d.day + n));
|
||||
return { year: t.getUTCFullYear(), month: t.getUTCMonth() + 1, day: t.getUTCDate() };
|
||||
}
|
||||
|
||||
export interface Window {
|
||||
/** Whether `at` is inside it. */
|
||||
open: boolean;
|
||||
/** The window `at` is in, or the next one. */
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
/** The weekend window an instant is in, or the next one if it is in none. */
|
||||
export function window(at: Date = now()): Window {
|
||||
const today = localDay(at);
|
||||
const sinceFriday = (today.weekday - 5 + 7) % 7; // Fri 0, Sat 1, Sun 2, Mon 3 …
|
||||
const open = sinceFriday <= 2;
|
||||
const friday = addDays(today, open ? -sinceFriday : 7 - sinceFriday);
|
||||
const monday = addDays(friday, 3);
|
||||
return {
|
||||
open,
|
||||
start: midnight(friday.year, friday.month, friday.day),
|
||||
end: midnight(monday.year, monday.month, monday.day)
|
||||
};
|
||||
}
|
||||
|
||||
/** Whether the trial is still offered: fewer than {@link PAID_SEATS} teams pay. */
|
||||
export async function offered(): Promise<boolean> {
|
||||
const [row] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ paid: count() })
|
||||
.from(TeamTable)
|
||||
.where(and(eq(TeamTable.plan, 'paid'), isNull(TeamTable.timeDeleted)))
|
||||
);
|
||||
return (row?.paid ?? 0) < PAID_SEATS;
|
||||
}
|
||||
|
||||
/** Live seconds of a person's trial runs inside `[start, end)`, up to `now`. */
|
||||
export async function usedSeconds(userId: string, w: Window, at: Date = now()) {
|
||||
const [row] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({
|
||||
seconds: sql<string>`coalesce(sum(greatest(0, extract(epoch from
|
||||
least(coalesce(${SessionTable.timeStopped}, ${at.toISOString()}::timestamptz), ${w.end.toISOString()}::timestamptz)
|
||||
- greatest(${SessionTable.timeStarted}, ${w.start.toISOString()}::timestamptz)))), 0)`
|
||||
})
|
||||
.from(SessionTable)
|
||||
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.userId, userId),
|
||||
eq(SessionTable.trial, true),
|
||||
sql`${SessionTable.timeStarted} is not null`,
|
||||
isNull(SessionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
);
|
||||
return Math.floor(Number(row?.seconds ?? 0));
|
||||
}
|
||||
|
||||
function when(at: Date): string {
|
||||
return new Intl.DateTimeFormat('en-GB', {
|
||||
timeZone: ZONE,
|
||||
weekday: 'long',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
timeZoneName: 'short'
|
||||
}).format(at);
|
||||
}
|
||||
|
||||
/**
|
||||
* Refuse a trial run that may not start, or record the claim and let it.
|
||||
*
|
||||
* The order is the order a person can act on: closed for everyone first,
|
||||
* then "not you" (somebody already used this address or account), then
|
||||
* "not now" (outside the window, or this window's hours are spent).
|
||||
*/
|
||||
export const assertMayStart = fn(
|
||||
z.object({
|
||||
teamId: z.string(),
|
||||
userId: z.string(),
|
||||
linkedAccountId: z.string(),
|
||||
now: z.date().optional()
|
||||
}),
|
||||
async (input) => {
|
||||
const at = input.now ?? now();
|
||||
if (!(await offered())) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'The free weekend has closed: Nestri GPUs are now taken by paying teams. A plan gets you on.'
|
||||
);
|
||||
}
|
||||
|
||||
await claim(input);
|
||||
|
||||
const w = window(at);
|
||||
if (!w.open) {
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
|
||||
`Free play on Nestri GPUs is Friday to Sunday. The next window opens ${when(w.start)}.`
|
||||
);
|
||||
}
|
||||
if ((await usedSeconds(input.userId, w, at)) >= SECONDS) {
|
||||
const next = window(w.end);
|
||||
throw new VisibleError(
|
||||
'rate_limit',
|
||||
ErrorCodes.RateLimit.QUOTA_EXCEEDED,
|
||||
`Your ${HOURS} free hours this weekend are used. The next window opens ${when(next.start)}.`
|
||||
);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* The team's claim, made on its first trial run.
|
||||
*
|
||||
* Checked by reading, then held by the unique indexes: two first runs in
|
||||
* the same instant both read "unclaimed", and the second insert is refused
|
||||
* with the same answer the read would have given.
|
||||
*/
|
||||
async function claim(input: { teamId: string; userId: string; linkedAccountId: string }) {
|
||||
const existing = await Database.use((tx) =>
|
||||
tx.select().from(TrialClaimTable).where(eq(TrialClaimTable.teamId, input.teamId))
|
||||
);
|
||||
if (existing.length > 0) return;
|
||||
|
||||
const [person] = await Database.use((tx) =>
|
||||
tx.select({ email: UserTable.email }).from(UserTable).where(eq(UserTable.id, input.userId))
|
||||
);
|
||||
const [linked] = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ steamId: LinkedAccountTable.providerAccountId })
|
||||
.from(LinkedAccountTable)
|
||||
.where(eq(LinkedAccountTable.id, input.linkedAccountId))
|
||||
);
|
||||
const email = person?.email?.trim().toLowerCase();
|
||||
if (!email || !linked) {
|
||||
throw new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'The free weekend needs a verified email address and a linked Steam account.'
|
||||
);
|
||||
}
|
||||
|
||||
const used = () =>
|
||||
new VisibleError(
|
||||
'forbidden',
|
||||
ErrorCodes.Permission.ACCOUNT_RESTRICTED,
|
||||
'This email address or Steam account has already had the free weekend. A plan gets you on.'
|
||||
);
|
||||
const taken = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ id: TrialClaimTable.id })
|
||||
.from(TrialClaimTable)
|
||||
.where(
|
||||
sql`lower(${TrialClaimTable.email}) = ${email} or ${TrialClaimTable.steamId} = ${linked.steamId}`
|
||||
)
|
||||
.limit(1)
|
||||
);
|
||||
if (taken.length > 0) throw used();
|
||||
|
||||
try {
|
||||
await Database.use((tx) =>
|
||||
tx.insert(TrialClaimTable).values({
|
||||
id: Identifier.ascending('trialClaim'),
|
||||
teamId: input.teamId,
|
||||
userId: input.userId,
|
||||
email,
|
||||
steamId: linked.steamId
|
||||
})
|
||||
);
|
||||
} catch (err) {
|
||||
const e = err as { code?: string; cause?: { code?: string } };
|
||||
if (e?.code === '23505' || e?.cause?.code === '23505') throw used();
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The trial runs on one machine that must be stopped now: live, and either
|
||||
* outside the window or past the hours. Asked when the host polls for
|
||||
* work, so a run is stopped within one poll of crossing the line.
|
||||
*/
|
||||
export async function overdue(machineId: string, at: Date = now()) {
|
||||
const live = await Database.use((tx) =>
|
||||
tx
|
||||
.select({ sessionId: SessionTable.id, boxId: BoxTable.id, userId: BoxTable.userId })
|
||||
.from(SessionTable)
|
||||
.innerJoin(BoxTable, eq(SessionTable.boxId, BoxTable.id))
|
||||
.innerJoin(MachineTable, eq(BoxTable.machineId, MachineTable.id))
|
||||
.where(
|
||||
and(
|
||||
eq(BoxTable.machineId, machineId),
|
||||
eq(SessionTable.trial, true),
|
||||
eq(SessionTable.state, 'live'),
|
||||
isNull(SessionTable.timeDeleted)
|
||||
)
|
||||
)
|
||||
);
|
||||
const w = window(at);
|
||||
const out: { sessionId: string; boxId: string; reason: 'window' | 'hours' }[] = [];
|
||||
for (const run of live) {
|
||||
if (!w.open) out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'window' });
|
||||
else if ((await usedSeconds(run.userId, w, at)) >= SECONDS) {
|
||||
out.push({ sessionId: run.sessionId, boxId: run.boxId, reason: 'hours' });
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { index, pgTable, text, uniqueIndex } from 'drizzle-orm/pg-core';
|
||||
|
||||
import { id, timestamps, ulid } from '../db/types.js';
|
||||
import { TeamTable } from '../team/team.sql.js';
|
||||
import { UserTable } from '../user/user.sql.js';
|
||||
|
||||
/**
|
||||
* Who has had the free weekend on rented GPUs.
|
||||
*
|
||||
* One per team, one per email address and one per Steam account: an address
|
||||
* costs nothing to make, an account that owns games costs a good deal more, so
|
||||
* a second trial needs both a new address and a new Steam account.
|
||||
*
|
||||
* The row outlives the account that made it. Deleting the team or the user
|
||||
* clears those links and keeps the address and the Steam id, so closing an
|
||||
* account and signing up again is not a second trial.
|
||||
*/
|
||||
export const TrialClaimTable = pgTable(
|
||||
'trial_claim',
|
||||
{
|
||||
...id,
|
||||
...timestamps,
|
||||
teamId: ulid('team_id').references(() => TeamTable.id, { onDelete: 'set null' }),
|
||||
userId: ulid('user_id').references(() => UserTable.id, { onDelete: 'set null' }),
|
||||
/** Lowercased. */
|
||||
email: text('email').notNull(),
|
||||
/** The Steam account's 64-bit id, as text. */
|
||||
steamId: text('steam_id').notNull()
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex('trial_claim_email_unique').on(t.email),
|
||||
uniqueIndex('trial_claim_steam_unique').on(t.steamId),
|
||||
uniqueIndex('trial_claim_team_unique').on(t.teamId),
|
||||
index('trial_claim_user_idx').on(t.userId)
|
||||
]
|
||||
);
|
||||
Reference in New Issue
Block a user