mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-20 01:35:19 +03:00
A host agent already sends a full inventory snapshot on a cadence, and nothing served the endpoint it sends it to — so every one of those calls answered 404. It fails quietly by design, because a dropped snapshot is meant to be corrected by the next one, which is exactly why nobody noticed: the only symptom is a line in the agent's own log. Kept separate from the heartbeat because the two have different loss tolerance. A dropped beat moves a host towards offline and unplaces it; a dropped snapshot costs nothing until the next one arrives. Folding them together would let a malformed inventory field make a healthy host look dead. Three rules decide what a snapshot may do, and the last two are why this is one core function rather than a loop in the route: - a box we know, that the snapshot names, takes the reported state - a box we know that was running, and that the snapshot omits, is stopped and says so — absence inside a snapshot is information - a box the snapshot names that is not placed on the calling host is never created, only reported back as a divergence The scope is in the `where` clause and not in the agent asking politely about its own boxes: a machine credential is a long-lived secret sitting on hardware in somebody's living room. `pid` and `uptimeS` are accepted and deliberately dropped. A pid is a number in another machine's namespace, and uptime is derivable from a run's start time, which is already stored and already trustworthy.
389 lines
13 KiB
TypeScript
389 lines
13 KiB
TypeScript
import { Actor } from '@nestri/core/actor';
|
||
import { Box } from '@nestri/core/box/index';
|
||
import { ErrorCodes, VisibleError } from '@nestri/core/error';
|
||
import { Examples } from '@nestri/core/examples';
|
||
import { Identifier } from '@nestri/core/id';
|
||
import { Machine } from '@nestri/core/machine/index';
|
||
import { Team } from '@nestri/core/team/index';
|
||
import { Member } from '@nestri/core/team/member';
|
||
import { Hono } from 'hono';
|
||
import { describeRoute } from 'hono-openapi';
|
||
import { z } from 'zod';
|
||
|
||
import { ErrorResponses, machineOnly, notPublic, Result, validator } from '../utils';
|
||
|
||
/**
|
||
* Host registration.
|
||
*
|
||
* A box does not get to say who it is. It registers once against its owner's
|
||
* session, is handed an id and a secret, and authenticates as itself from then
|
||
* on — so `hostId` on a download report is something the API assigned rather
|
||
* than a free-form string any holder of a shared secret could invent.
|
||
*/
|
||
export namespace MachineApi {
|
||
export const route = new Hono()
|
||
.post(
|
||
'/register',
|
||
notPublic,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Register a nessh host',
|
||
description:
|
||
'Exchange the calling user session for a machine id and secret. The secret is returned once and never again — it is stored only as a digest.',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.object({
|
||
machineId: z.string().meta({ example: Examples.Machine.id }),
|
||
secret: z.string().meta({
|
||
description: 'Shown once. Store it on the box; it cannot be retrieved.'
|
||
})
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'The box is registered'
|
||
},
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z.object({
|
||
label: z.string().min(1).max(64).meta({
|
||
description: 'Human-readable name for the box',
|
||
example: Examples.Machine.label
|
||
}),
|
||
teamId: z.string().optional().meta({
|
||
description:
|
||
'Team to own this hardware. Defaults to the caller’s personal team, which always exists'
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { label, teamId } = c.req.valid('json');
|
||
|
||
// `notPublic` also admits admin, which has no user to own the box.
|
||
// Registering is an act of ownership, so it needs a real one.
|
||
const actor = Actor.use();
|
||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||
throw new VisibleError(
|
||
'forbidden',
|
||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||
'Registering a machine requires a user session'
|
||
);
|
||
}
|
||
|
||
// A team has to be resolved rather than defaulted to null, because
|
||
// `machine.teamId` is notNull. The order is: what the caller asked
|
||
// for, then the team they are acting inside, then their personal
|
||
// team — which `ensurePersonal` makes if this is an older user who
|
||
// has none. ref(d-0048)
|
||
const owningTeam =
|
||
teamId ??
|
||
(actor.type === 'member'
|
||
? actor.properties.teamID
|
||
: await Team.ensurePersonal({ displayName: Actor.userID }));
|
||
|
||
// A caller naming a team must belong to it. Without this, `teamId`
|
||
// would be a way to park hardware in somebody else's team.
|
||
if (teamId) {
|
||
const membership = await Member.findByTeamAndUser({
|
||
teamId,
|
||
userId: Actor.userID
|
||
});
|
||
if (!membership) {
|
||
throw new VisibleError(
|
||
'forbidden',
|
||
ErrorCodes.Permission.FORBIDDEN,
|
||
'You are not a member of that team'
|
||
);
|
||
}
|
||
}
|
||
|
||
const registered = await Machine.register({
|
||
id: Identifier.ascending('machine'),
|
||
ownerUserId: Actor.userID,
|
||
teamId: owningTeam,
|
||
label
|
||
});
|
||
|
||
return c.json({ data: { machineId: registered.id, secret: registered.secret } });
|
||
}
|
||
)
|
||
.patch(
|
||
'/:id',
|
||
notPublic,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Move a box to another team',
|
||
description:
|
||
'Move a machine you own to a team you belong to. Hardware always belongs to exactly one team, so there is no way to unscope — name your personal team instead. This is not ownership transfer: the owner does not change.',
|
||
responses: {
|
||
200: {
|
||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||
description: 'The machine, rescoped'
|
||
},
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z.object({
|
||
teamId: z.string().meta({
|
||
description:
|
||
'Team to move the box to. There is no “no team” — to unscope, name your personal team'
|
||
})
|
||
})
|
||
),
|
||
async (c) => {
|
||
const { teamId } = c.req.valid('json');
|
||
|
||
const actor = Actor.use();
|
||
if (actor.type !== 'user' && actor.type !== 'member') {
|
||
throw new VisibleError(
|
||
'forbidden',
|
||
ErrorCodes.Permission.INSUFFICIENT_PERMISSIONS,
|
||
'Rescoping a machine requires a user session'
|
||
);
|
||
}
|
||
|
||
// Verified before the write. `setTeam` scopes to the owner but
|
||
// knows nothing about who belongs to the target team, so this is
|
||
// the only place that check exists.
|
||
const membership = await Member.findByTeamAndUser({
|
||
teamId,
|
||
userId: Actor.userID
|
||
});
|
||
if (!membership) {
|
||
throw new VisibleError(
|
||
'forbidden',
|
||
ErrorCodes.Permission.FORBIDDEN,
|
||
'You are not a member of that team'
|
||
);
|
||
}
|
||
|
||
const machine = await Machine.setTeam({
|
||
id: c.req.param('id'),
|
||
ownerUserId: Actor.userID,
|
||
teamId
|
||
});
|
||
if (!machine) {
|
||
// Owner-scoped in the query, so someone else's machine is a
|
||
// 404 rather than a 403 — no way to probe for ids.
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
'No such machine, or it is not yours'
|
||
);
|
||
}
|
||
return c.json({ data: machine });
|
||
}
|
||
)
|
||
.get(
|
||
'/entitlement',
|
||
machineOnly,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Ask whether a user may use this box',
|
||
description:
|
||
'Answers for the calling machine only — the machine is taken from its credentials, never from the query, so a box cannot ask about another. Membership is read live, so removing someone from a team removes their access.',
|
||
responses: {
|
||
200: {
|
||
content: { 'application/json': { schema: Result(Machine.Entitlement) } },
|
||
description: 'Whether the user may use this machine, and why'
|
||
},
|
||
403: ErrorResponses[403]
|
||
}
|
||
}),
|
||
validator('query', z.object({ userId: z.string().min(1) })),
|
||
async (c) => {
|
||
const { userId } = c.req.valid('query');
|
||
return c.json({
|
||
data: await Machine.entitlement({ machineId: Actor.machineID, userId })
|
||
});
|
||
}
|
||
)
|
||
.post(
|
||
'/heartbeat',
|
||
machineOnly,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Say the host is alive',
|
||
description:
|
||
'Records liveness for the calling machine and returns how often it should call back. The interval comes from the server on purpose: a fleet whose cadence can only change by shipping a new agent is a fleet whose cadence never changes. Takes no body — what a host is *running* is reported separately, and reporting a shape we cannot yet act on would be worse than reporting nothing.',
|
||
responses: {
|
||
200: {
|
||
content: {
|
||
'application/json': {
|
||
schema: Result(
|
||
z.object({
|
||
lastSeen: z.iso.datetime().meta({
|
||
description: 'When this beat was recorded, by the database’s clock',
|
||
example: Examples.Machine.lastSeen
|
||
}),
|
||
intervalSeconds: z.number().meta({
|
||
description: 'Call back this often',
|
||
example: Machine.HEARTBEAT_SECONDS
|
||
})
|
||
})
|
||
)
|
||
}
|
||
},
|
||
description: 'The beat was recorded'
|
||
},
|
||
403: ErrorResponses[403],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
async (c) => {
|
||
const lastSeen = await Machine.touchLastSeen(Actor.machineID);
|
||
if (!lastSeen) {
|
||
// The credentials authenticated but the row is gone — a host
|
||
// deleted mid-beat. It must re-register rather than keep
|
||
// beating into nothing, so this is a 404 and not a 200.
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
'This machine no longer exists'
|
||
);
|
||
}
|
||
return c.json({
|
||
data: {
|
||
lastSeen: lastSeen.toISOString(),
|
||
intervalSeconds: Machine.HEARTBEAT_SECONDS
|
||
}
|
||
});
|
||
}
|
||
)
|
||
.post(
|
||
'/report',
|
||
machineOnly,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Say what the host is running',
|
||
description:
|
||
'Records one full snapshot of the boxes on the calling host. Separate from the beat because the two have different loss tolerance: a dropped report is corrected by the next one, where a dropped beat moves a host towards offline. Send one when a box changes lifecycle, and send one anyway every so often so a single lost snapshot cannot leave this record permanently wrong. Never send a delta — a retrying agent cannot promise ordering, and out-of-order deltas describe a host that never existed.',
|
||
responses: {
|
||
200: {
|
||
content: { 'application/json': { schema: Result(Box.ReportOutcome) } },
|
||
description: 'The snapshot was recorded'
|
||
},
|
||
400: ErrorResponses[400],
|
||
403: ErrorResponses[403],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
validator(
|
||
'json',
|
||
z
|
||
.object({
|
||
agentPid: z.number().int().meta({
|
||
description: 'The reporting agent’s own process id, in its own namespace'
|
||
}),
|
||
boxesKnown: z.number().int().meta({ description: 'How many boxes the host holds' }),
|
||
boxesRunning: z.number().int().meta({
|
||
description: 'How many of them are running'
|
||
}),
|
||
boxes: z.array(Box.Reported).meta({
|
||
description: 'Every box the host holds. A full snapshot, never a delta'
|
||
})
|
||
})
|
||
// Strict, so a field this cannot act on is a validation error a
|
||
// host operator sees rather than one quietly dropped. Capacity
|
||
// belongs here eventually and it has no honest fields yet;
|
||
// refusing the ones nobody measures is how it stays that way.
|
||
.strict()
|
||
),
|
||
async (c) => {
|
||
const machine = await Machine.fromID(Actor.machineID);
|
||
if (!machine) {
|
||
// Same answer as the beat gives, for the same reason: the
|
||
// credentials authenticated but the row is gone, and a host
|
||
// must re-register rather than keep reporting into nothing.
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
'This machine no longer exists'
|
||
);
|
||
}
|
||
|
||
const { boxes } = c.req.valid('json');
|
||
const outcome = await Box.applyHostReport({ machineId: Actor.machineID, boxes });
|
||
|
||
// `agentPid`, `boxesKnown` and `boxesRunning` are read and not
|
||
// stored. They are a summary of the list that follows them, and a
|
||
// stored copy is a second answer to a question the list already
|
||
// answers — one that goes stale the first time the two disagree.
|
||
// They stay on the wire because a host that cannot enumerate its
|
||
// boxes can still say how many it has.
|
||
if (outcome.unknown.length > 0) {
|
||
// Loudly, per the contract this endpoint is built to: a host
|
||
// holding boxes nobody placed there is a bug to surface, not a
|
||
// state to reconcile quietly. Nothing is created for them.
|
||
// eslint-disable-next-line no-console
|
||
console.warn(
|
||
'host report named boxes that are not placed here:',
|
||
Actor.machineID,
|
||
outcome.unknown.join(', ')
|
||
);
|
||
}
|
||
|
||
return c.json({ data: outcome });
|
||
}
|
||
)
|
||
.get(
|
||
'/me',
|
||
machineOnly,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'Describe the calling machine',
|
||
description:
|
||
'Returns the registration record for the credentials used. A box calls this at startup to confirm its credentials still work before relying on them.',
|
||
responses: {
|
||
200: {
|
||
content: { 'application/json': { schema: Result(Machine.Info) } },
|
||
description: 'The calling machine'
|
||
},
|
||
403: ErrorResponses[403],
|
||
404: ErrorResponses[404]
|
||
}
|
||
}),
|
||
async (c) => {
|
||
const machine = await Machine.fromID(Actor.machineID);
|
||
if (!machine) {
|
||
throw new VisibleError(
|
||
'not_found',
|
||
ErrorCodes.NotFound.RESOURCE_NOT_FOUND,
|
||
'This machine no longer exists'
|
||
);
|
||
}
|
||
return c.json({ data: machine });
|
||
}
|
||
)
|
||
.get(
|
||
'/',
|
||
notPublic,
|
||
describeRoute({
|
||
tags: ['Machine'],
|
||
summary: 'List your registered hosts',
|
||
responses: {
|
||
200: {
|
||
content: { 'application/json': { schema: Result(z.array(Machine.Info)) } },
|
||
description: 'Machines owned by the caller'
|
||
},
|
||
401: ErrorResponses[401],
|
||
403: ErrorResponses[403]
|
||
}
|
||
}),
|
||
async (c) => {
|
||
return c.json({ data: await Machine.listByOwner(Actor.userID) });
|
||
}
|
||
);
|
||
}
|