@telorun/runner-core 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +17 -0
- package/README.md +38 -0
- package/dist/backend.d.ts +71 -0
- package/dist/backend.d.ts.map +1 -0
- package/dist/backend.js +2 -0
- package/dist/backend.js.map +1 -0
- package/dist/base-image-catalog.d.ts +110 -0
- package/dist/base-image-catalog.d.ts.map +1 -0
- package/dist/base-image-catalog.js +245 -0
- package/dist/base-image-catalog.js.map +1 -0
- package/dist/capabilities-schema.d.ts +33 -0
- package/dist/capabilities-schema.d.ts.map +1 -0
- package/dist/capabilities-schema.js +44 -0
- package/dist/capabilities-schema.js.map +1 -0
- package/dist/config.d.ts +37 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +93 -0
- package/dist/config.js.map +1 -0
- package/dist/contract.d.ts +170 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +24 -0
- package/dist/contract.js.map +1 -0
- package/dist/debug/relay.d.ts +25 -0
- package/dist/debug/relay.d.ts.map +1 -0
- package/dist/debug/relay.js +89 -0
- package/dist/debug/relay.js.map +1 -0
- package/dist/dependency-key.d.ts +38 -0
- package/dist/dependency-key.d.ts.map +1 -0
- package/dist/dependency-key.js +68 -0
- package/dist/dependency-key.js.map +1 -0
- package/dist/index.d.ts +20 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/routes/capabilities.d.ts +12 -0
- package/dist/routes/capabilities.d.ts.map +1 -0
- package/dist/routes/capabilities.js +15 -0
- package/dist/routes/capabilities.js.map +1 -0
- package/dist/routes/health.d.ts +5 -0
- package/dist/routes/health.d.ts.map +1 -0
- package/dist/routes/health.js +8 -0
- package/dist/routes/health.js.map +1 -0
- package/dist/routes/io.d.ts +8 -0
- package/dist/routes/io.d.ts.map +1 -0
- package/dist/routes/io.js +239 -0
- package/dist/routes/io.js.map +1 -0
- package/dist/routes/probe.d.ts +7 -0
- package/dist/routes/probe.d.ts.map +1 -0
- package/dist/routes/probe.js +22 -0
- package/dist/routes/probe.js.map +1 -0
- package/dist/routes/sessions.d.ts +22 -0
- package/dist/routes/sessions.d.ts.map +1 -0
- package/dist/routes/sessions.js +223 -0
- package/dist/routes/sessions.js.map +1 -0
- package/dist/server.d.ts +34 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +67 -0
- package/dist/server.js.map +1 -0
- package/dist/session/bundle-path.d.ts +12 -0
- package/dist/session/bundle-path.d.ts.map +1 -0
- package/dist/session/bundle-path.js +27 -0
- package/dist/session/bundle-path.js.map +1 -0
- package/dist/session/byte-ring-buffer.d.ts +29 -0
- package/dist/session/byte-ring-buffer.d.ts.map +1 -0
- package/dist/session/byte-ring-buffer.js +54 -0
- package/dist/session/byte-ring-buffer.js.map +1 -0
- package/dist/session/registry.d.ts +62 -0
- package/dist/session/registry.d.ts.map +1 -0
- package/dist/session/registry.js +156 -0
- package/dist/session/registry.js.map +1 -0
- package/dist/session/ring-buffer.d.ts +37 -0
- package/dist/session/ring-buffer.d.ts.map +1 -0
- package/dist/session/ring-buffer.js +64 -0
- package/dist/session/ring-buffer.js.map +1 -0
- package/dist/session/session-id.d.ts +3 -0
- package/dist/session/session-id.d.ts.map +1 -0
- package/dist/session/session-id.js +19 -0
- package/dist/session/session-id.js.map +1 -0
- package/dist/sse/channel.d.ts +11 -0
- package/dist/sse/channel.d.ts.map +1 -0
- package/dist/sse/channel.js +129 -0
- package/dist/sse/channel.js.map +1 -0
- package/package.json +48 -0
- package/src/backend.ts +88 -0
- package/src/base-image-catalog.test.ts +209 -0
- package/src/base-image-catalog.ts +320 -0
- package/src/capabilities-schema.test.ts +54 -0
- package/src/capabilities-schema.ts +71 -0
- package/src/config.ts +122 -0
- package/src/contract.ts +170 -0
- package/src/debug/relay.ts +104 -0
- package/src/dependency-key.test.ts +64 -0
- package/src/dependency-key.ts +105 -0
- package/src/index.ts +33 -0
- package/src/routes/capabilities.ts +20 -0
- package/src/routes/health.ts +9 -0
- package/src/routes/io.ts +265 -0
- package/src/routes/probe.ts +35 -0
- package/src/routes/sessions.ts +270 -0
- package/src/server.ts +108 -0
- package/src/session/bundle-path.ts +27 -0
- package/src/session/byte-ring-buffer.ts +62 -0
- package/src/session/registry.test.ts +34 -0
- package/src/session/registry.ts +185 -0
- package/src/session/ring-buffer.test.ts +54 -0
- package/src/session/ring-buffer.ts +75 -0
- package/src/session/session-id.test.ts +17 -0
- package/src/session/session-id.ts +20 -0
- package/src/sse/channel.ts +154 -0
package/src/routes/io.ts
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync, FastifyRequest } from "fastify";
|
|
2
|
+
import type { WebSocket } from "@fastify/websocket";
|
|
3
|
+
|
|
4
|
+
import { isTerminal } from "../contract.js";
|
|
5
|
+
import type { BufferedBytes } from "../session/byte-ring-buffer.js";
|
|
6
|
+
import type { SessionRegistry } from "../session/registry.js";
|
|
7
|
+
|
|
8
|
+
export interface IoRouteDeps {
|
|
9
|
+
registry: SessionRegistry;
|
|
10
|
+
corsOrigins: string[] | "*";
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const RESIZE_THROTTLE_MS = 50;
|
|
14
|
+
const SEQ_PREFIX_BYTES = 4;
|
|
15
|
+
/** Upper bound on cols/rows accepted from the client. xterm + a fit addon
|
|
16
|
+
* produce values in the low thousands at extreme zoom; anything past this
|
|
17
|
+
* is either nonsense or a malicious client trying to feed
|
|
18
|
+
* `Number.MAX_SAFE_INTEGER` straight to the workload. */
|
|
19
|
+
const MAX_RESIZE_DIMENSION = 10_000;
|
|
20
|
+
const TERMINAL_DRAIN_INTERVAL_MS = 50;
|
|
21
|
+
const TERMINAL_DRAIN_MAX_MS = 2_000;
|
|
22
|
+
|
|
23
|
+
interface ControlFrame {
|
|
24
|
+
type: string;
|
|
25
|
+
cols?: number;
|
|
26
|
+
rows?: number;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function ioRoute(deps: IoRouteDeps): FastifyPluginAsync {
|
|
30
|
+
return async (app: FastifyInstance) => {
|
|
31
|
+
app.get<{ Params: { id: string }; Querystring: { lastSeq?: string } }>(
|
|
32
|
+
"/v1/sessions/:id/io",
|
|
33
|
+
{ websocket: true },
|
|
34
|
+
(socket, req) => handleIo(socket, req, deps),
|
|
35
|
+
);
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function handleIo(
|
|
40
|
+
socket: WebSocket,
|
|
41
|
+
req: FastifyRequest<{ Params: { id: string }; Querystring: { lastSeq?: string } }>,
|
|
42
|
+
deps: IoRouteDeps,
|
|
43
|
+
): void {
|
|
44
|
+
// Origin allowlist runs INSIDE the handler (post-handshake) rather than
|
|
45
|
+
// in a preValidation hook. Reason: a 403 HTTP response on a failed
|
|
46
|
+
// upgrade is invisible to browser WebSocket clients — they only ever
|
|
47
|
+
// see close code 1006 (abnormal closure), which is indistinguishable
|
|
48
|
+
// from a transient network failure and triggers exponential-backoff
|
|
49
|
+
// reconnect loops forever. Closing with an application code (4403) the
|
|
50
|
+
// browser CAN read lets the client fail fast.
|
|
51
|
+
const origin = headerString(req.headers.origin);
|
|
52
|
+
if (!isOriginAllowed(deps.corsOrigins, origin)) {
|
|
53
|
+
closeWith(socket, 4403, "forbidden_origin");
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const sessionId = req.params.id;
|
|
58
|
+
const entry = deps.registry.get(sessionId);
|
|
59
|
+
if (!entry) {
|
|
60
|
+
closeWith(socket, 4404, "session not found");
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const lastSeq = parseLastSeq(req.query.lastSeq);
|
|
65
|
+
|
|
66
|
+
// Subscribe BEFORE snapshotting the replay buffer. This is load-bearing:
|
|
67
|
+
// a `pushBytes` that fires between snapshot and subscribe would otherwise
|
|
68
|
+
// be lost, with no way for either side to detect the gap. Bytes that
|
|
69
|
+
// arrive during the deferred window are queued; once replay is sent, the
|
|
70
|
+
// queue is drained (filtering anything seq-overlapping with replay) and
|
|
71
|
+
// the handler switches to direct-send mode.
|
|
72
|
+
let mode: "deferred" | "direct" = "deferred";
|
|
73
|
+
const liveQueue: BufferedBytes[] = [];
|
|
74
|
+
const unsubscribe = deps.registry.subscribeBytes(sessionId, (buffered) => {
|
|
75
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
76
|
+
if (mode === "deferred") {
|
|
77
|
+
liveQueue.push(buffered);
|
|
78
|
+
} else {
|
|
79
|
+
sendBytesFrame(socket, buffered);
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const { entries, hasGap } = entry.byteBuffer.replay(lastSeq);
|
|
84
|
+
|
|
85
|
+
// If the session is already terminal AND the byte buffer holds nothing
|
|
86
|
+
// newer than what the client already saw, there's no live channel to
|
|
87
|
+
// attach to — close 4410 so the client falls back to status-only display.
|
|
88
|
+
if (isTerminal(entry.status) && entries.length === 0 && liveQueue.length === 0) {
|
|
89
|
+
unsubscribe();
|
|
90
|
+
closeWith(socket, 4410, "session terminal, nothing to replay");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Confirm the resume point so the client can validate its own bookkeeping.
|
|
95
|
+
sendJson(socket, { type: "seq", seq: lastSeq });
|
|
96
|
+
if (hasGap) {
|
|
97
|
+
sendJson(socket, { type: "gap", reason: "buffer_evicted" });
|
|
98
|
+
}
|
|
99
|
+
for (const buffered of entries) {
|
|
100
|
+
sendBytesFrame(socket, buffered);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Drain any live pushes that landed during the deferred window. Replay
|
|
104
|
+
// entries are seq-monotonic, so anything queued with seq <= the last
|
|
105
|
+
// replayed seq is a duplicate and must be dropped.
|
|
106
|
+
const lastReplayedSeq =
|
|
107
|
+
entries.length > 0 ? entries[entries.length - 1]!.seq : lastSeq;
|
|
108
|
+
for (const buffered of liveQueue) {
|
|
109
|
+
if (buffered.seq > lastReplayedSeq) {
|
|
110
|
+
sendBytesFrame(socket, buffered);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
liveQueue.length = 0;
|
|
114
|
+
// Synchronous flip — JS event loop guarantees no pushBytes can fire
|
|
115
|
+
// between the drain loop above and this assignment, so the handler
|
|
116
|
+
// doesn't need to lock.
|
|
117
|
+
mode = "direct";
|
|
118
|
+
|
|
119
|
+
let resizeTimer: NodeJS.Timeout | null = null;
|
|
120
|
+
let pendingResize: { cols: number; rows: number } | null = null;
|
|
121
|
+
const flushResize = (): void => {
|
|
122
|
+
resizeTimer = null;
|
|
123
|
+
const next = pendingResize;
|
|
124
|
+
pendingResize = null;
|
|
125
|
+
if (!next) return;
|
|
126
|
+
// Session may have exited between schedule and flush; backends treat
|
|
127
|
+
// resize on a gone workload as a no-op.
|
|
128
|
+
entry.session?.resize(next.cols, next.rows);
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
socket.on("message", (raw, isBinary) => {
|
|
132
|
+
if (isBinary) {
|
|
133
|
+
const session = entry.session;
|
|
134
|
+
if (!session) return;
|
|
135
|
+
const buf = raw instanceof Buffer ? raw : Buffer.from(raw as ArrayBuffer);
|
|
136
|
+
session.writeStdin(buf);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const text = raw.toString();
|
|
140
|
+
let parsed: ControlFrame;
|
|
141
|
+
try {
|
|
142
|
+
parsed = JSON.parse(text) as ControlFrame;
|
|
143
|
+
} catch {
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (parsed.type === "resize") {
|
|
147
|
+
const cols = clampDimension(parsed.cols);
|
|
148
|
+
const rows = clampDimension(parsed.rows);
|
|
149
|
+
if (cols === null || rows === null) return;
|
|
150
|
+
pendingResize = { cols, rows };
|
|
151
|
+
if (resizeTimer === null) {
|
|
152
|
+
resizeTimer = setTimeout(flushResize, RESIZE_THROTTLE_MS);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
// Close the socket when the session reaches terminal status — replay
|
|
158
|
+
// already covered the buffered bytes, no live bytes will arrive after
|
|
159
|
+
// this, and clients can rely on socket close as their "stream finished"
|
|
160
|
+
// signal alongside the SSE status frame. We poll `bufferedAmount` so
|
|
161
|
+
// that an immediate close doesn't drop frames still queued on the
|
|
162
|
+
// server-side socket.
|
|
163
|
+
let drainTimer: NodeJS.Timeout | null = null;
|
|
164
|
+
const drainAndClose = (deadline: number): void => {
|
|
165
|
+
drainTimer = null;
|
|
166
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
167
|
+
if (socket.bufferedAmount === 0 || Date.now() >= deadline) {
|
|
168
|
+
try {
|
|
169
|
+
socket.close(1000, "session terminal");
|
|
170
|
+
} catch {
|
|
171
|
+
/* already closed */
|
|
172
|
+
}
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
drainTimer = setTimeout(() => drainAndClose(deadline), TERMINAL_DRAIN_INTERVAL_MS);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const unsubscribeStatus = deps.registry.subscribe(sessionId, (buffered) => {
|
|
179
|
+
if (buffered.event.type !== "status") return;
|
|
180
|
+
if (!isTerminal(entry.status)) return;
|
|
181
|
+
if (drainTimer !== null) return;
|
|
182
|
+
drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
// Late-connect case: the session was already terminal at connect time
|
|
186
|
+
// and had replayable bytes (so we didn't take the 4410 fast path). The
|
|
187
|
+
// status subscription above will never fire — so the socket would
|
|
188
|
+
// otherwise stay open forever after replay completes. Schedule directly.
|
|
189
|
+
if (isTerminal(entry.status) && drainTimer === null) {
|
|
190
|
+
drainAndClose(Date.now() + TERMINAL_DRAIN_MAX_MS);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const cleanup = (): void => {
|
|
194
|
+
unsubscribe();
|
|
195
|
+
unsubscribeStatus();
|
|
196
|
+
if (resizeTimer) clearTimeout(resizeTimer);
|
|
197
|
+
if (drainTimer) clearTimeout(drainTimer);
|
|
198
|
+
};
|
|
199
|
+
socket.on("close", cleanup);
|
|
200
|
+
socket.on("error", cleanup);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Wire format for a binary frame: `[seq:4 BE][payload:N]`. The 4-byte
|
|
204
|
+
* prefix lets the client de-sync detect — it knows the authoritative seq
|
|
205
|
+
* for every byte received, instead of inferring from frame count. Replay
|
|
206
|
+
* duplicates and reconnect resumes both lean on this. */
|
|
207
|
+
function sendBytesFrame(socket: WebSocket, buffered: BufferedBytes): void {
|
|
208
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
209
|
+
const prefix = Buffer.alloc(SEQ_PREFIX_BYTES);
|
|
210
|
+
prefix.writeUInt32BE(buffered.seq, 0);
|
|
211
|
+
try {
|
|
212
|
+
socket.send(Buffer.concat([prefix, buffered.bytes]));
|
|
213
|
+
} catch {
|
|
214
|
+
/* socket closed under our feet */
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function closeWith(socket: WebSocket, code: number, reason: string): void {
|
|
219
|
+
try {
|
|
220
|
+
socket.close(code, reason);
|
|
221
|
+
} catch {
|
|
222
|
+
/* socket already closed */
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function sendJson(socket: WebSocket, payload: unknown): void {
|
|
227
|
+
if (socket.readyState !== socket.OPEN) return;
|
|
228
|
+
try {
|
|
229
|
+
socket.send(JSON.stringify(payload));
|
|
230
|
+
} catch {
|
|
231
|
+
/* socket closed under our feet */
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function parseLastSeq(raw: string | undefined): number {
|
|
236
|
+
if (!raw) return 0;
|
|
237
|
+
const n = Number.parseInt(raw, 10);
|
|
238
|
+
return Number.isFinite(n) && n >= 0 ? n : 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function headerString(value: string | string[] | undefined): string | undefined {
|
|
242
|
+
if (Array.isArray(value)) return value[0];
|
|
243
|
+
return value;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function clampDimension(raw: unknown): number | null {
|
|
247
|
+
const n = Number(raw);
|
|
248
|
+
if (!Number.isFinite(n)) return null;
|
|
249
|
+
const floored = Math.floor(n);
|
|
250
|
+
if (floored < 1) return null;
|
|
251
|
+
return Math.min(floored, MAX_RESIZE_DIMENSION);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function isOriginAllowed(
|
|
255
|
+
corsOrigins: string[] | "*",
|
|
256
|
+
origin: string | undefined,
|
|
257
|
+
): boolean {
|
|
258
|
+
if (corsOrigins === "*") return true;
|
|
259
|
+
// Browsers ALWAYS send Origin on WebSocket upgrades — a missing Origin
|
|
260
|
+
// means the request is from a non-browser (curl, internal script, an
|
|
261
|
+
// attacker who stripped the header). When an explicit allowlist is
|
|
262
|
+
// configured, an absent Origin is a rejection.
|
|
263
|
+
if (!origin) return false;
|
|
264
|
+
return corsOrigins.includes(origin);
|
|
265
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync } from "fastify";
|
|
2
|
+
|
|
3
|
+
import type { RunnerBackend } from "../backend.js";
|
|
4
|
+
import type { ProbeConfig } from "../contract.js";
|
|
5
|
+
|
|
6
|
+
export interface ProbeRouteDeps {
|
|
7
|
+
backend: RunnerBackend;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const bodySchema = {
|
|
11
|
+
type: "object",
|
|
12
|
+
required: ["config"],
|
|
13
|
+
additionalProperties: false,
|
|
14
|
+
properties: {
|
|
15
|
+
config: {
|
|
16
|
+
type: "object",
|
|
17
|
+
required: ["image", "pullPolicy"],
|
|
18
|
+
additionalProperties: false,
|
|
19
|
+
properties: {
|
|
20
|
+
image: { type: "string", minLength: 1 },
|
|
21
|
+
pullPolicy: { type: "string", enum: ["missing", "always", "never"] },
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
},
|
|
25
|
+
} as const;
|
|
26
|
+
|
|
27
|
+
export function probeRoute(deps: ProbeRouteDeps): FastifyPluginAsync {
|
|
28
|
+
return async (app: FastifyInstance) => {
|
|
29
|
+
app.post<{ Body: { config: ProbeConfig } }>(
|
|
30
|
+
"/v1/probe",
|
|
31
|
+
{ schema: { body: bodySchema } },
|
|
32
|
+
async (req) => deps.backend.probe(req.body.config),
|
|
33
|
+
);
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,270 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
|
|
2
|
+
|
|
3
|
+
import { isEventFrame } from "@telorun/debug-wire";
|
|
4
|
+
import type { RunnerBackend } from "../backend.js";
|
|
5
|
+
import {
|
|
6
|
+
ACCEPTED_TERMS_HEADER,
|
|
7
|
+
SessionStartError,
|
|
8
|
+
type RunnerTerms,
|
|
9
|
+
type SessionConfig,
|
|
10
|
+
type StartSessionRequest,
|
|
11
|
+
} from "../contract.js";
|
|
12
|
+
import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
|
|
13
|
+
import { generateSessionId } from "../session/session-id.js";
|
|
14
|
+
import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
|
|
15
|
+
import { streamSessionEvents } from "../sse/channel.js";
|
|
16
|
+
|
|
17
|
+
export interface SessionsRouteDeps {
|
|
18
|
+
backend: RunnerBackend;
|
|
19
|
+
registry: SessionRegistry;
|
|
20
|
+
corsOrigins: string[] | "*";
|
|
21
|
+
/** The runner's own default registry URL, surfaced to the workload as
|
|
22
|
+
* TELO_REGISTRY_URL when the request doesn't override it. */
|
|
23
|
+
defaultRegistryUrl?: string;
|
|
24
|
+
/** When set, a session may only start if the client acknowledges this exact
|
|
25
|
+
* terms version via the `x-telo-accepted-terms` header. */
|
|
26
|
+
terms?: RunnerTerms;
|
|
27
|
+
/** Backend-supplied config gate. Returns an error message to reject the
|
|
28
|
+
* request with `400 invalid_config`, or `undefined` to accept. The runner is
|
|
29
|
+
* the source of truth, so this re-checks what `/v1/capabilities` advertises
|
|
30
|
+
* (e.g. an `image` allowlist) against a client that skipped the editor. */
|
|
31
|
+
validateConfig?: (config: SessionConfig) => string | undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const startBodySchema = {
|
|
35
|
+
type: "object",
|
|
36
|
+
required: ["bundle", "env", "config"],
|
|
37
|
+
properties: {
|
|
38
|
+
bundle: {
|
|
39
|
+
type: "object",
|
|
40
|
+
required: ["entryRelativePath", "files"],
|
|
41
|
+
properties: {
|
|
42
|
+
entryRelativePath: { type: "string", minLength: 1 },
|
|
43
|
+
files: {
|
|
44
|
+
type: "array",
|
|
45
|
+
items: {
|
|
46
|
+
type: "object",
|
|
47
|
+
required: ["relativePath", "contents"],
|
|
48
|
+
properties: {
|
|
49
|
+
relativePath: { type: "string", minLength: 1 },
|
|
50
|
+
contents: { type: "string" },
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
env: {
|
|
57
|
+
type: "object",
|
|
58
|
+
additionalProperties: { type: "string" },
|
|
59
|
+
},
|
|
60
|
+
ports: {
|
|
61
|
+
type: "array",
|
|
62
|
+
items: {
|
|
63
|
+
type: "object",
|
|
64
|
+
required: ["port", "protocol"],
|
|
65
|
+
properties: {
|
|
66
|
+
port: { type: "integer", minimum: 1, maximum: 65535 },
|
|
67
|
+
protocol: { type: "string", enum: ["tcp", "udp"] },
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
},
|
|
71
|
+
config: {
|
|
72
|
+
type: "object",
|
|
73
|
+
required: ["image", "pullPolicy"],
|
|
74
|
+
properties: {
|
|
75
|
+
image: { type: "string", minLength: 1 },
|
|
76
|
+
pullPolicy: { type: "string", enum: ["missing", "always", "never"] },
|
|
77
|
+
registryUrl: { type: "string", minLength: 1 },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
inspect: { type: "boolean" },
|
|
81
|
+
},
|
|
82
|
+
} as const;
|
|
83
|
+
|
|
84
|
+
export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
|
|
85
|
+
return async (app: FastifyInstance) => {
|
|
86
|
+
app.post<{ Body: StartSessionRequest }>(
|
|
87
|
+
"/v1/sessions",
|
|
88
|
+
{ schema: { body: startBodySchema } },
|
|
89
|
+
async (req, reply) => {
|
|
90
|
+
// Terms enforcement — the server is the source of truth, so a client that
|
|
91
|
+
// skips the editor gate still can't start a session without acknowledging
|
|
92
|
+
// the current terms version.
|
|
93
|
+
if (deps.terms) {
|
|
94
|
+
const raw = req.headers[ACCEPTED_TERMS_HEADER];
|
|
95
|
+
const accepted = Array.isArray(raw) ? raw[0] : raw;
|
|
96
|
+
if (accepted !== deps.terms.version) {
|
|
97
|
+
reply.code(428).send({ error: "terms_required", terms: deps.terms });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return startSession(app, deps, req.body, reply);
|
|
102
|
+
},
|
|
103
|
+
);
|
|
104
|
+
|
|
105
|
+
app.get<{ Params: { id: string } }>("/v1/sessions/:id", async (req, reply) => {
|
|
106
|
+
const entry = deps.registry.get(req.params.id);
|
|
107
|
+
if (!entry) {
|
|
108
|
+
reply.code(404).send({ error: "not_found", message: `session '${req.params.id}' not in registry` });
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
reply.send({
|
|
112
|
+
sessionId: entry.sessionId,
|
|
113
|
+
status: entry.status,
|
|
114
|
+
createdAt: entry.createdAt.toISOString(),
|
|
115
|
+
exitedAt: entry.exitedAt?.toISOString(),
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
app.delete<{ Params: { id: string } }>("/v1/sessions/:id", async (req, reply) => {
|
|
120
|
+
const entry = deps.registry.get(req.params.id);
|
|
121
|
+
if (!entry) {
|
|
122
|
+
reply.code(204).send();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
entry.userStopped = true;
|
|
126
|
+
if (entry.session) {
|
|
127
|
+
try {
|
|
128
|
+
await entry.session.stop();
|
|
129
|
+
} catch (err) {
|
|
130
|
+
app.log.error({ err, sessionId: entry.sessionId }, "failed to stop session");
|
|
131
|
+
reply.code(500).send({ error: "stop_failed", message: (err as Error).message });
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
reply.code(204).send();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
app.get<{ Params: { id: string }; Querystring: { lastEventId?: string } }>(
|
|
139
|
+
"/v1/sessions/:id/events",
|
|
140
|
+
async (req, reply) =>
|
|
141
|
+
streamSessionEvents({
|
|
142
|
+
registry: deps.registry,
|
|
143
|
+
req,
|
|
144
|
+
reply,
|
|
145
|
+
sessionId: req.params.id,
|
|
146
|
+
corsOrigins: deps.corsOrigins,
|
|
147
|
+
}),
|
|
148
|
+
);
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function startSession(
|
|
153
|
+
app: FastifyInstance,
|
|
154
|
+
deps: SessionsRouteDeps,
|
|
155
|
+
body: StartSessionRequest,
|
|
156
|
+
reply: FastifyReply,
|
|
157
|
+
): Promise<void> {
|
|
158
|
+
const sessionId = generateSessionId();
|
|
159
|
+
|
|
160
|
+
let entryRelative: string;
|
|
161
|
+
try {
|
|
162
|
+
// Traversal guard for the entry path and every bundle file — a `../foo`
|
|
163
|
+
// would let the workload read or execute paths outside its session dir.
|
|
164
|
+
// Validated here (backend-neutral) so a bad path is a 400, not a backend
|
|
165
|
+
// 500, regardless of how the backend ultimately delivers the bundle.
|
|
166
|
+
entryRelative = normalizeBundlePath(body.bundle.entryRelativePath);
|
|
167
|
+
for (const file of body.bundle.files) normalizeBundlePath(file.relativePath);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (err instanceof BundlePathError) {
|
|
170
|
+
reply.code(400).send({ error: "invalid_bundle", message: err.message });
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
throw err;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// Backend config gate (e.g. an image allowlist). The advertised capabilities
|
|
177
|
+
// constrain the editor; this enforces the same against any client.
|
|
178
|
+
if (deps.validateConfig) {
|
|
179
|
+
const message = deps.validateConfig(body.config);
|
|
180
|
+
if (message) {
|
|
181
|
+
reply.code(400).send({ error: "invalid_config", message });
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
let entry: ReturnType<SessionRegistry["register"]>;
|
|
187
|
+
try {
|
|
188
|
+
entry = deps.registry.register({ sessionId });
|
|
189
|
+
} catch (err) {
|
|
190
|
+
if (err instanceof SessionLimitError) {
|
|
191
|
+
reply.code(409).send({ error: "too_many_sessions", message: err.message });
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
throw err;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
|
|
198
|
+
// it up. Precedence: body.env explicit value > body.config.registryUrl
|
|
199
|
+
// (per-request override) > runner's own default. Trim client-supplied URLs
|
|
200
|
+
// so stray whitespace from an editor input doesn't flow into the workload.
|
|
201
|
+
const configRegistryUrl = body.config.registryUrl?.trim() || undefined;
|
|
202
|
+
const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
|
|
203
|
+
const sessionEnv =
|
|
204
|
+
registryUrl && !("TELO_REGISTRY_URL" in body.env)
|
|
205
|
+
? { ...body.env, TELO_REGISTRY_URL: registryUrl }
|
|
206
|
+
: body.env;
|
|
207
|
+
|
|
208
|
+
// Respond as soon as the session is registered — BEFORE the backend starts.
|
|
209
|
+
// `backend.start()` now spans the on-cluster image build and pod bring-up,
|
|
210
|
+
// which can take seconds-to-minutes; awaiting it here would hide the event
|
|
211
|
+
// stream until the workload is already up, so the client never sees build /
|
|
212
|
+
// provision / boot progress live. Returning the streamUrl first lets the
|
|
213
|
+
// client connect immediately; start runs in the background and its progress,
|
|
214
|
+
// output, and terminal status flow over the stream.
|
|
215
|
+
reply.code(201).send({
|
|
216
|
+
sessionId,
|
|
217
|
+
streamUrl: `/v1/sessions/${sessionId}/events`,
|
|
218
|
+
createdAt: entry.createdAt.toISOString(),
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
deps.backend
|
|
222
|
+
.start({
|
|
223
|
+
sessionId,
|
|
224
|
+
bundle: body.bundle,
|
|
225
|
+
entryRelativePath: entryRelative,
|
|
226
|
+
env: sessionEnv,
|
|
227
|
+
ports: body.ports ?? [],
|
|
228
|
+
config: body.config,
|
|
229
|
+
inspect: body.inspect ?? false,
|
|
230
|
+
onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
|
|
231
|
+
onProgress: (phase, message, done) =>
|
|
232
|
+
deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
|
|
233
|
+
onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
|
|
234
|
+
// Relay only kernel *event* frames to the client. stdout/stderr already
|
|
235
|
+
// arrive over the byte channel (onOutput), so forwarding log frames would
|
|
236
|
+
// double the traffic and let log spam evict lifecycle events from the
|
|
237
|
+
// byte-capped replay buffer. The editor discards relayed logs anyway.
|
|
238
|
+
onDebug: (frame) => {
|
|
239
|
+
if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
|
|
240
|
+
},
|
|
241
|
+
isUserStopped: () => entry.userStopped,
|
|
242
|
+
})
|
|
243
|
+
.then(async (session) => {
|
|
244
|
+
entry.session = session;
|
|
245
|
+
// Pre-start DELETE race: a DELETE received during backend.start (e.g.
|
|
246
|
+
// while an image build was running) can't stop a workload that didn't
|
|
247
|
+
// exist yet — it set userStopped and returned 204. Now that the workload
|
|
248
|
+
// is live, honor the earlier DELETE.
|
|
249
|
+
if (entry.userStopped) {
|
|
250
|
+
try {
|
|
251
|
+
await session.stop();
|
|
252
|
+
} catch (err) {
|
|
253
|
+
app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
.catch((err) => {
|
|
258
|
+
// The 201 is already sent, so a start failure surfaces as a terminal
|
|
259
|
+
// `failed` status on the stream (the registry schedules eviction on a
|
|
260
|
+
// terminal status; the SSE channel delivers it, then closes).
|
|
261
|
+
const message =
|
|
262
|
+
err instanceof SessionStartError
|
|
263
|
+
? `${err.stage}: ${err.message}`
|
|
264
|
+
: err instanceof Error
|
|
265
|
+
? err.message
|
|
266
|
+
: String(err);
|
|
267
|
+
app.log.error({ err, sessionId }, "session start failed");
|
|
268
|
+
deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
|
|
269
|
+
});
|
|
270
|
+
}
|
package/src/server.ts
ADDED
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import Fastify, { type FastifyBaseLogger, type FastifyInstance } from "fastify";
|
|
2
|
+
import cors from "@fastify/cors";
|
|
3
|
+
import websocket from "@fastify/websocket";
|
|
4
|
+
|
|
5
|
+
import type { RunnerBackend } from "./backend.js";
|
|
6
|
+
import type { RunnerCoreConfig } from "./config.js";
|
|
7
|
+
import type { RunnerCapabilities, SessionConfig } from "./contract.js";
|
|
8
|
+
import { capabilitiesRoute } from "./routes/capabilities.js";
|
|
9
|
+
import { healthRoute } from "./routes/health.js";
|
|
10
|
+
import { ioRoute } from "./routes/io.js";
|
|
11
|
+
import { probeRoute } from "./routes/probe.js";
|
|
12
|
+
import { sessionsRoute } from "./routes/sessions.js";
|
|
13
|
+
import { SessionRegistry } from "./session/registry.js";
|
|
14
|
+
|
|
15
|
+
export interface ServerDeps {
|
|
16
|
+
backend: RunnerBackend;
|
|
17
|
+
config: RunnerCoreConfig;
|
|
18
|
+
/** The concrete runner's package version, surfaced on /v1/health. */
|
|
19
|
+
version: string;
|
|
20
|
+
/** The runner's self-description + editable config schema, served on
|
|
21
|
+
* /v1/capabilities so the editor renders a generic runner config form. A
|
|
22
|
+
* getter is re-resolved per request — use it when the config surface changes
|
|
23
|
+
* at runtime (e.g. a base-image catalog refreshed from a registry). */
|
|
24
|
+
capabilities: RunnerCapabilities | (() => RunnerCapabilities);
|
|
25
|
+
/** Runner's default registry URL, passed to workloads as TELO_REGISTRY_URL. */
|
|
26
|
+
defaultRegistryUrl?: string;
|
|
27
|
+
/** Backend config gate, enforced on `POST /v1/sessions` before the workload
|
|
28
|
+
* starts (e.g. an `image` allowlist). Rejects with `400 invalid_config`. */
|
|
29
|
+
validateConfig?: (config: SessionConfig) => string | undefined;
|
|
30
|
+
registry?: SessionRegistry;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ServerHandle {
|
|
34
|
+
app: FastifyInstance;
|
|
35
|
+
registry: SessionRegistry;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
39
|
+
const app = Fastify({
|
|
40
|
+
logger: { level: deps.config.logLevel },
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
// CORS: SSE and fetch from the editor's browser origin are cross-origin by
|
|
44
|
+
// default. A runner with no auth is driven by whoever can reach the port, so
|
|
45
|
+
// default to `*` and let operators narrow via RUNNER_CORS_ORIGINS.
|
|
46
|
+
await app.register(cors, {
|
|
47
|
+
origin: deps.config.corsOrigins,
|
|
48
|
+
methods: ["GET", "POST", "DELETE", "OPTIONS"],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
await app.register(websocket);
|
|
52
|
+
|
|
53
|
+
const registry =
|
|
54
|
+
deps.registry ??
|
|
55
|
+
new SessionRegistry({
|
|
56
|
+
maxSessions: deps.config.maxSessions,
|
|
57
|
+
exitTtlMs: deps.config.exitTtlMs,
|
|
58
|
+
replayBufferBytes: deps.config.replayBufferBytes,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// Terms are stable across the process — resolve the capabilities once for them
|
|
62
|
+
// even when `capabilities` is a getter (the route still re-resolves per request).
|
|
63
|
+
const capabilitiesValue =
|
|
64
|
+
typeof deps.capabilities === "function" ? deps.capabilities() : deps.capabilities;
|
|
65
|
+
|
|
66
|
+
await app.register(healthRoute(deps.version));
|
|
67
|
+
await app.register(capabilitiesRoute(deps.capabilities));
|
|
68
|
+
await app.register(probeRoute({ backend: deps.backend }));
|
|
69
|
+
await app.register(
|
|
70
|
+
sessionsRoute({
|
|
71
|
+
backend: deps.backend,
|
|
72
|
+
registry,
|
|
73
|
+
corsOrigins: deps.config.corsOrigins,
|
|
74
|
+
defaultRegistryUrl: deps.defaultRegistryUrl,
|
|
75
|
+
validateConfig: deps.validateConfig,
|
|
76
|
+
// The capabilities document is the single source of the runner's terms;
|
|
77
|
+
// the session route enforces what /v1/capabilities advertises.
|
|
78
|
+
terms: capabilitiesValue.terms,
|
|
79
|
+
}),
|
|
80
|
+
);
|
|
81
|
+
await app.register(ioRoute({ registry, corsOrigins: deps.config.corsOrigins }));
|
|
82
|
+
|
|
83
|
+
return { app, registry };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Stop every live session. Used by graceful shutdown — marks each entry
|
|
88
|
+
* userStopped and stops its backend workload so nothing leaks past process
|
|
89
|
+
* exit. Backend-neutral: it only touches the abstract `BackendSession`.
|
|
90
|
+
*/
|
|
91
|
+
export async function stopAllSessions(
|
|
92
|
+
registry: SessionRegistry,
|
|
93
|
+
log: Pick<FastifyBaseLogger, "info" | "warn">,
|
|
94
|
+
): Promise<void> {
|
|
95
|
+
const live = registry.list().filter((e) => e.session !== null && e.exitedAt === null);
|
|
96
|
+
if (live.length === 0) return;
|
|
97
|
+
log.info({ count: live.length }, "stopping live sessions before shutdown");
|
|
98
|
+
await Promise.all(
|
|
99
|
+
live.map(async (entry) => {
|
|
100
|
+
entry.userStopped = true;
|
|
101
|
+
try {
|
|
102
|
+
await entry.session?.stop();
|
|
103
|
+
} catch (err) {
|
|
104
|
+
log.warn({ err, sessionId: entry.sessionId }, "failed to stop session during shutdown");
|
|
105
|
+
}
|
|
106
|
+
}),
|
|
107
|
+
);
|
|
108
|
+
}
|