mirror of
https://github.com/nestriness/nestri.git
synced 2026-09-19 17:25:19 +03:00
feat(api): a host can say it is alive, and is told how often to
Second half of G1's "neslet registers against api.nestri.io and heartbeats".
Registration already worked; there was no heartbeat endpoint at all — grep for
it across apps/api and packages/core returned nothing, and neslet's own
main.rs says the same from its side.
POST /machine/heartbeat, machine credentials only. Two decisions worth stating
because neither is obvious from the diff:
**It returns the interval.** The auth middleware already touches lastSeen on
every authenticated machine request, so an endpoint that only did that would
add an endpoint and no capability. What a host cannot know on its own is how
often the control plane wants to hear from it, so the response carries the
cadence. A fleet whose interval can only change by shipping a new agent is a
fleet whose interval never changes.
**It takes no body.** neslet has a HostSummary ready to send, and week 2 owns
box state reporting. Accepting fields nothing acts on yet would mean a wire
shape we would have to keep, chosen before the thing that consumes it exists.
Online-ness is derived from lastSeen rather than stored: a host that stops
beating goes offline through the passage of time, which is the one mechanism
that cannot itself fail. Three missed beats, not one — a single missed beat is
a lost packet, and treating that as offline would make placement flap.
Also: the machine actor's teamID stops being optional. It was `...(teamId ? {}
: {})` in the middleware, a branch for a state that cannot exist now that
machine.team_id is notNull.
134 tests, 0 fail.
This commit is contained in:
@@ -80,7 +80,7 @@ export const auth: MiddlewareHandler = async (c, next) => {
|
||||
properties: {
|
||||
machineID: machine.id,
|
||||
ownerUserID: machine.ownerUserId,
|
||||
...(machine.teamId ? { teamID: machine.teamId } : {})
|
||||
teamID: machine.teamId
|
||||
}
|
||||
},
|
||||
next
|
||||
|
||||
@@ -208,6 +208,58 @@ export namespace MachineApi {
|
||||
});
|
||||
}
|
||||
)
|
||||
.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
|
||||
}
|
||||
});
|
||||
}
|
||||
)
|
||||
.get(
|
||||
'/me',
|
||||
machineOnly,
|
||||
|
||||
Reference in New Issue
Block a user