@telorun/runner-core 0.7.0 → 0.8.1

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.
@@ -0,0 +1,174 @@
1
+ import type { FastifyInstance, FastifyReply, FastifyRequest } 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 PortMapping,
9
+ type RunBundle,
10
+ type RunnerTerms,
11
+ type SessionConfig,
12
+ } from "../contract.js";
13
+ import { generateSessionId } from "../session/session-id.js";
14
+ import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
15
+
16
+ /** JSON Schema for the `ports` body field, shared by every session-creating
17
+ * route so bundle and app sessions validate port mappings identically. */
18
+ export const portsSchema = {
19
+ type: "array",
20
+ items: {
21
+ type: "object",
22
+ required: ["port", "protocol"],
23
+ properties: {
24
+ port: { type: "integer", minimum: 1, maximum: 65535 },
25
+ protocol: { type: "string", enum: ["tcp", "udp"] },
26
+ },
27
+ },
28
+ } as const;
29
+
30
+ /**
31
+ * Terms enforcement shared by every session-creating route — the server is the
32
+ * source of truth, so a client that skips the editor gate still can't start a
33
+ * workload without acknowledging the current terms version. Sends the 428 and
34
+ * returns false when the gate is closed.
35
+ */
36
+ export function enforceTerms(
37
+ req: FastifyRequest,
38
+ reply: FastifyReply,
39
+ terms: RunnerTerms | undefined,
40
+ ): boolean {
41
+ if (!terms) return true;
42
+ const raw = req.headers[ACCEPTED_TERMS_HEADER];
43
+ const accepted = Array.isArray(raw) ? raw[0] : raw;
44
+ if (accepted !== terms.version) {
45
+ reply.code(428).send({ error: "terms_required", terms });
46
+ return false;
47
+ }
48
+ return true;
49
+ }
50
+
51
+ /** Dependencies the shared session-creation leaf needs, independent of which
52
+ * route drives it. Both `SessionsRouteDeps` and `AppsRouteDeps` satisfy it. */
53
+ export interface WorkloadStartDeps {
54
+ backend: RunnerBackend;
55
+ registry: SessionRegistry;
56
+ /** The runner's own default registry URL, surfaced to the workload as
57
+ * TELO_REGISTRY_URL when the request doesn't override it. */
58
+ defaultRegistryUrl?: string;
59
+ }
60
+
61
+ export interface WorkloadStartArgs {
62
+ bundle: RunBundle;
63
+ entryRelativePath: string;
64
+ env: Record<string, string>;
65
+ ports: PortMapping[];
66
+ config: SessionConfig;
67
+ selfContained: boolean;
68
+ inspect: boolean;
69
+ }
70
+
71
+ /**
72
+ * The session-creation leaf shared by `POST /v1/sessions` (bundle sessions) and
73
+ * `POST /v1/apps/:name/sessions` (operator-predefined apps): registers the
74
+ * session, responds 201 with the shared `/v1/sessions/:id/events` stream URL,
75
+ * and starts the workload in the background. Whatever door a session was
76
+ * created through, everything after creation lives in the one session
77
+ * collection (status, DELETE, events, io).
78
+ */
79
+ export async function startWorkloadSession(
80
+ app: FastifyInstance,
81
+ deps: WorkloadStartDeps,
82
+ args: WorkloadStartArgs,
83
+ reply: FastifyReply,
84
+ ): Promise<void> {
85
+ const sessionId = generateSessionId();
86
+
87
+ let entry: ReturnType<SessionRegistry["register"]>;
88
+ try {
89
+ entry = deps.registry.register({ sessionId });
90
+ } catch (err) {
91
+ if (err instanceof SessionLimitError) {
92
+ reply.code(409).send({ error: "too_many_sessions", message: err.message });
93
+ return;
94
+ }
95
+ throw err;
96
+ }
97
+
98
+ // Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
99
+ // it up. Precedence: explicit env value > config.registryUrl (per-request
100
+ // override) > runner's own default. Trim client-supplied URLs so stray
101
+ // whitespace from an editor input doesn't flow into the workload.
102
+ const configRegistryUrl = args.config.registryUrl?.trim() || undefined;
103
+ const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
104
+ const sessionEnv =
105
+ registryUrl && !("TELO_REGISTRY_URL" in args.env)
106
+ ? { ...args.env, TELO_REGISTRY_URL: registryUrl }
107
+ : args.env;
108
+
109
+ // Respond as soon as the session is registered — BEFORE the backend starts.
110
+ // `backend.start()` now spans the on-cluster image build and pod bring-up,
111
+ // which can take seconds-to-minutes; awaiting it here would hide the event
112
+ // stream until the workload is already up, so the client never sees build /
113
+ // provision / boot progress live. Returning the streamUrl first lets the
114
+ // client connect immediately; start runs in the background and its progress,
115
+ // output, and terminal status flow over the stream.
116
+ reply.code(201).send({
117
+ sessionId,
118
+ streamUrl: `/v1/sessions/${sessionId}/events`,
119
+ createdAt: entry.createdAt.toISOString(),
120
+ });
121
+
122
+ deps.backend
123
+ .start({
124
+ sessionId,
125
+ bundle: args.bundle,
126
+ entryRelativePath: args.entryRelativePath,
127
+ env: sessionEnv,
128
+ ports: args.ports,
129
+ config: args.config,
130
+ selfContained: args.selfContained,
131
+ inspect: args.inspect,
132
+ onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
133
+ onProgress: (phase, message, done) =>
134
+ deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
135
+ onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
136
+ // Relay only kernel *event* frames to the client. stdout/stderr already
137
+ // arrive over the byte channel (onOutput), so forwarding log frames would
138
+ // double the traffic and let log spam evict lifecycle events from the
139
+ // byte-capped replay buffer. The editor discards relayed logs anyway.
140
+ onDebug: (frame) => {
141
+ if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
142
+ },
143
+ onReachability: (port, state) =>
144
+ deps.registry.emit(sessionId, { type: "reachability", port, state }),
145
+ isUserStopped: () => entry.userStopped,
146
+ })
147
+ .then(async (session) => {
148
+ entry.session = session;
149
+ // Pre-start DELETE race: a DELETE received during backend.start (e.g.
150
+ // while an image build was running) can't stop a workload that didn't
151
+ // exist yet — it set userStopped and returned 204. Now that the workload
152
+ // is live, honor the earlier DELETE.
153
+ if (entry.userStopped) {
154
+ try {
155
+ await session.stop();
156
+ } catch (err) {
157
+ app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
158
+ }
159
+ }
160
+ })
161
+ .catch((err) => {
162
+ // The 201 is already sent, so a start failure surfaces as a terminal
163
+ // `failed` status on the stream (the registry schedules eviction on a
164
+ // terminal status; the SSE channel delivers it, then closes).
165
+ const message =
166
+ err instanceof SessionStartError
167
+ ? `${err.stage}: ${err.message}`
168
+ : err instanceof Error
169
+ ? err.message
170
+ : String(err);
171
+ app.log.error({ err, sessionId }, "session start failed");
172
+ deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
173
+ });
174
+ }
@@ -1,19 +1,10 @@
1
1
  import type { FastifyInstance, FastifyPluginAsync, FastifyReply } from "fastify";
2
2
 
3
- import { isEventFrame } from "@telorun/debug-wire";
4
3
  import type { RunnerBackend } from "../backend.js";
5
- import {
6
- ACCEPTED_TERMS_HEADER,
7
- SessionStartError,
8
- type RunBundle,
9
- type RunnerTerms,
10
- type SessionConfig,
11
- type StartSessionRequest,
12
- } from "../contract.js";
13
- import type { ResolvedRunnerApp } from "../config.js";
4
+ import type { RunnerTerms, SessionConfig, StartSessionRequest } from "../contract.js";
14
5
  import { BundlePathError, normalizeBundlePath } from "../session/bundle-path.js";
15
- import { generateSessionId } from "../session/session-id.js";
16
- import { SessionLimitError, type SessionRegistry } from "../session/registry.js";
6
+ import type { SessionRegistry } from "../session/registry.js";
7
+ import { enforceTerms, portsSchema, startWorkloadSession } from "./session-start.js";
17
8
  import { streamSessionEvents } from "../sse/channel.js";
18
9
 
19
10
  export interface SessionsRouteDeps {
@@ -26,10 +17,6 @@ export interface SessionsRouteDeps {
26
17
  /** When set, a session may only start if the client acknowledges this exact
27
18
  * terms version via the `x-telo-accepted-terms` header. */
28
19
  terms?: RunnerTerms;
29
- /** Operator-predefined applications launchable by name
30
- * (`StartSessionRequest.app`), with their operator env already resolved.
31
- * The catalog is the whole gate — an unknown name is rejected. */
32
- apps?: Record<string, ResolvedRunnerApp>;
33
20
  /** Backend-supplied config gate. Returns an error message to reject the
34
21
  * request with `400 invalid_config`, or `undefined` to accept. The runner is
35
22
  * the source of truth, so this re-checks what `/v1/capabilities` advertises
@@ -37,13 +24,10 @@ export interface SessionsRouteDeps {
37
24
  validateConfig?: (config: SessionConfig) => string | undefined;
38
25
  }
39
26
 
40
- // `bundle`/`config` are schema-optional because an `app` session needs neither;
41
- // the route enforces their presence for regular bundle sessions.
42
27
  const startBodySchema = {
43
28
  type: "object",
44
- required: ["env"],
29
+ required: ["bundle", "env", "config"],
45
30
  properties: {
46
- app: { type: "string", minLength: 1 },
47
31
  bundle: {
48
32
  type: "object",
49
33
  required: ["entryRelativePath", "files"],
@@ -66,17 +50,7 @@ const startBodySchema = {
66
50
  type: "object",
67
51
  additionalProperties: { type: "string" },
68
52
  },
69
- ports: {
70
- type: "array",
71
- items: {
72
- type: "object",
73
- required: ["port", "protocol"],
74
- properties: {
75
- port: { type: "integer", minimum: 1, maximum: 65535 },
76
- protocol: { type: "string", enum: ["tcp", "udp"] },
77
- },
78
- },
79
- },
53
+ ports: portsSchema,
80
54
  config: {
81
55
  type: "object",
82
56
  required: ["image", "pullPolicy"],
@@ -96,17 +70,7 @@ export function sessionsRoute(deps: SessionsRouteDeps): FastifyPluginAsync {
96
70
  "/v1/sessions",
97
71
  { schema: { body: startBodySchema } },
98
72
  async (req, reply) => {
99
- // Terms enforcement — the server is the source of truth, so a client that
100
- // skips the editor gate still can't start a session without acknowledging
101
- // the current terms version.
102
- if (deps.terms) {
103
- const raw = req.headers[ACCEPTED_TERMS_HEADER];
104
- const accepted = Array.isArray(raw) ? raw[0] : raw;
105
- if (accepted !== deps.terms.version) {
106
- reply.code(428).send({ error: "terms_required", terms: deps.terms });
107
- return;
108
- }
109
- }
73
+ if (!enforceTerms(req, reply, deps.terms)) return;
110
74
  return startSession(app, deps, req.body, reply);
111
75
  },
112
76
  );
@@ -164,170 +128,44 @@ async function startSession(
164
128
  body: StartSessionRequest,
165
129
  reply: FastifyReply,
166
130
  ): Promise<void> {
167
- const sessionId = generateSessionId();
168
-
169
- // App sessions launch an operator-predefined image by name: the catalog
170
- // resolves the image and operator env server-side, so the client can neither
171
- // pick the image nor reach the secrets — the catalog IS the gate, and an
172
- // unknown name is rejected here.
173
- const appEntry = body.app === undefined ? undefined : deps.apps?.[body.app];
174
- if (body.app !== undefined && !appEntry) {
175
- const offered = Object.keys(deps.apps ?? {});
176
- reply.code(400).send({
177
- error: "unknown_app",
178
- message:
179
- `app '${body.app}' is not offered by this runner` +
180
- (offered.length > 0
181
- ? ` — offered apps: ${offered.join(", ")}`
182
- : " — it offers no predefined applications") +
183
- " (see /v1/capabilities).",
184
- });
185
- return;
186
- }
187
- if (!appEntry && (!body.bundle || !body.config)) {
188
- reply.code(400).send({
189
- error: "invalid_request",
190
- message: "'bundle' and 'config' are required unless launching a predefined app via 'app'.",
191
- });
192
- return;
193
- }
194
-
195
- let bundle: RunBundle;
196
131
  let entryRelative: string;
197
- let config: SessionConfig;
198
- if (appEntry) {
199
- // Self-contained image — no bundle to deliver; the entry path is an unused
200
- // placeholder so the backend spec stays total.
201
- bundle = { entryRelativePath: "telo.yaml", files: [] };
202
- entryRelative = bundle.entryRelativePath;
203
- config = { image: appEntry.image, pullPolicy: appEntry.pullPolicy };
204
- } else {
205
- bundle = body.bundle!;
206
- config = body.config!;
207
- try {
208
- // Traversal guard for the entry path and every bundle file — a `../foo`
209
- // would let the workload read or execute paths outside its session dir.
210
- // Validated here (backend-neutral) so a bad path is a 400, not a backend
211
- // 500, regardless of how the backend ultimately delivers the bundle.
212
- entryRelative = normalizeBundlePath(bundle.entryRelativePath);
213
- for (const file of bundle.files) normalizeBundlePath(file.relativePath);
214
- } catch (err) {
215
- if (err instanceof BundlePathError) {
216
- reply.code(400).send({ error: "invalid_bundle", message: err.message });
217
- return;
218
- }
219
- throw err;
220
- }
221
-
222
- // Backend config gate (e.g. an image allowlist). The advertised capabilities
223
- // constrain the editor; this enforces the same against any client. App
224
- // sessions skip it — their image comes from the catalog, not the client.
225
- if (deps.validateConfig) {
226
- const message = deps.validateConfig(config);
227
- if (message) {
228
- reply.code(400).send({ error: "invalid_config", message });
229
- return;
230
- }
231
- }
232
- }
233
-
234
- let entry: ReturnType<SessionRegistry["register"]>;
235
132
  try {
236
- entry = deps.registry.register({ sessionId });
133
+ // Traversal guard for the entry path and every bundle file — a `../foo`
134
+ // would let the workload read or execute paths outside its session dir.
135
+ // Validated here (backend-neutral) so a bad path is a 400, not a backend
136
+ // 500, regardless of how the backend ultimately delivers the bundle.
137
+ entryRelative = normalizeBundlePath(body.bundle.entryRelativePath);
138
+ for (const file of body.bundle.files) normalizeBundlePath(file.relativePath);
237
139
  } catch (err) {
238
- if (err instanceof SessionLimitError) {
239
- reply.code(409).send({ error: "too_many_sessions", message: err.message });
140
+ if (err instanceof BundlePathError) {
141
+ reply.code(400).send({ error: "invalid_bundle", message: err.message });
240
142
  return;
241
143
  }
242
144
  throw err;
243
145
  }
244
146
 
245
- // For an app session, drop client-supplied values for any env key the
246
- // catalog defines (a client must never override operator-held values, which
247
- // include secrets), then inject the operator's values.
248
- const clientEnv = appEntry
249
- ? {
250
- ...Object.fromEntries(
251
- Object.entries(body.env).filter(([key]) => !(key in appEntry.env)),
252
- ),
253
- ...appEntry.env,
254
- }
255
- : body.env;
256
-
257
- // Surface a TELO_REGISTRY_URL to the workload so the telo CLI inside picks
258
- // it up. Precedence: explicit env value > config.registryUrl (per-request
259
- // override) > runner's own default. Trim client-supplied URLs so stray
260
- // whitespace from an editor input doesn't flow into the workload.
261
- const configRegistryUrl = config.registryUrl?.trim() || undefined;
262
- const registryUrl = configRegistryUrl ?? deps.defaultRegistryUrl;
263
- const sessionEnv =
264
- registryUrl && !("TELO_REGISTRY_URL" in clientEnv)
265
- ? { ...clientEnv, TELO_REGISTRY_URL: registryUrl }
266
- : clientEnv;
267
-
268
- // Respond as soon as the session is registered — BEFORE the backend starts.
269
- // `backend.start()` now spans the on-cluster image build and pod bring-up,
270
- // which can take seconds-to-minutes; awaiting it here would hide the event
271
- // stream until the workload is already up, so the client never sees build /
272
- // provision / boot progress live. Returning the streamUrl first lets the
273
- // client connect immediately; start runs in the background and its progress,
274
- // output, and terminal status flow over the stream.
275
- reply.code(201).send({
276
- sessionId,
277
- streamUrl: `/v1/sessions/${sessionId}/events`,
278
- createdAt: entry.createdAt.toISOString(),
279
- });
147
+ // Backend config gate (e.g. an image allowlist). The advertised capabilities
148
+ // constrain the editor; this enforces the same against any client.
149
+ if (deps.validateConfig) {
150
+ const message = deps.validateConfig(body.config);
151
+ if (message) {
152
+ reply.code(400).send({ error: "invalid_config", message });
153
+ return;
154
+ }
155
+ }
280
156
 
281
- deps.backend
282
- .start({
283
- sessionId,
284
- bundle,
157
+ return startWorkloadSession(
158
+ app,
159
+ deps,
160
+ {
161
+ bundle: body.bundle,
285
162
  entryRelativePath: entryRelative,
286
- env: sessionEnv,
163
+ env: body.env,
287
164
  ports: body.ports ?? [],
288
- config,
289
- selfContained: appEntry !== undefined,
165
+ config: body.config,
166
+ selfContained: false,
290
167
  inspect: body.inspect ?? false,
291
- onStatus: (status) => deps.registry.emit(sessionId, { type: "status", status }),
292
- onProgress: (phase, message, done) =>
293
- deps.registry.emit(sessionId, { type: "progress", phase, message, done }),
294
- onOutput: (chunk) => deps.registry.pushBytes(sessionId, chunk),
295
- // Relay only kernel *event* frames to the client. stdout/stderr already
296
- // arrive over the byte channel (onOutput), so forwarding log frames would
297
- // double the traffic and let log spam evict lifecycle events from the
298
- // byte-capped replay buffer. The editor discards relayed logs anyway.
299
- onDebug: (frame) => {
300
- if (isEventFrame(frame)) deps.registry.emit(sessionId, { type: "debug", frame });
301
- },
302
- onReachability: (port, state) =>
303
- deps.registry.emit(sessionId, { type: "reachability", port, state }),
304
- isUserStopped: () => entry.userStopped,
305
- })
306
- .then(async (session) => {
307
- entry.session = session;
308
- // Pre-start DELETE race: a DELETE received during backend.start (e.g.
309
- // while an image build was running) can't stop a workload that didn't
310
- // exist yet — it set userStopped and returned 204. Now that the workload
311
- // is live, honor the earlier DELETE.
312
- if (entry.userStopped) {
313
- try {
314
- await session.stop();
315
- } catch (err) {
316
- app.log.warn({ err, sessionId }, "failed to stop after race with pre-start DELETE");
317
- }
318
- }
319
- })
320
- .catch((err) => {
321
- // The 201 is already sent, so a start failure surfaces as a terminal
322
- // `failed` status on the stream (the registry schedules eviction on a
323
- // terminal status; the SSE channel delivers it, then closes).
324
- const message =
325
- err instanceof SessionStartError
326
- ? `${err.stage}: ${err.message}`
327
- : err instanceof Error
328
- ? err.message
329
- : String(err);
330
- app.log.error({ err, sessionId }, "session start failed");
331
- deps.registry.emit(sessionId, { type: "status", status: { kind: "failed", message } });
332
- });
168
+ },
169
+ reply,
170
+ );
333
171
  }
package/src/server.ts CHANGED
@@ -5,6 +5,7 @@ import websocket from "@fastify/websocket";
5
5
  import type { RunnerBackend } from "./backend.js";
6
6
  import type { ResolvedRunnerApp, RunnerCoreConfig } from "./config.js";
7
7
  import type { RunnerAppDescriptor, RunnerCapabilities, SessionConfig } from "./contract.js";
8
+ import { appsRoute } from "./routes/apps.js";
8
9
  import { capabilitiesRoute } from "./routes/capabilities.js";
9
10
  import { healthRoute } from "./routes/health.js";
10
11
  import { ioRoute } from "./routes/io.js";
@@ -30,7 +31,8 @@ export interface ServerDeps {
30
31
  validateConfig?: (config: SessionConfig) => string | undefined;
31
32
  /** Operator-predefined applications launchable by name (usually
32
33
  * `loadResolvedApps(process.env)`). Advertised on /v1/capabilities as
33
- * `apps` descriptors; the session route resolves and gates against it. */
34
+ * `apps` descriptors; sessions of them are created via
35
+ * `POST /v1/apps/:name/sessions`. */
34
36
  apps?: Record<string, ResolvedRunnerApp>;
35
37
  registry?: SessionRegistry;
36
38
  }
@@ -92,7 +94,15 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
92
94
  defaultRegistryUrl: deps.defaultRegistryUrl,
93
95
  validateConfig: deps.validateConfig,
94
96
  // The capabilities document is the single source of the runner's terms;
95
- // the session route enforces what /v1/capabilities advertises.
97
+ // every session-creating route enforces what /v1/capabilities advertises.
98
+ terms: capabilitiesValue.terms,
99
+ }),
100
+ );
101
+ await app.register(
102
+ appsRoute({
103
+ backend: deps.backend,
104
+ registry,
105
+ defaultRegistryUrl: deps.defaultRegistryUrl,
96
106
  terms: capabilitiesValue.terms,
97
107
  apps: deps.apps,
98
108
  }),