@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.
- package/dist/backend.d.ts +1 -1
- package/dist/contract.d.ts +5 -12
- package/dist/contract.d.ts.map +1 -1
- package/dist/contract.js.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/index.js.map +1 -1
- package/dist/routes/apps.d.ts +29 -0
- package/dist/routes/apps.d.ts.map +1 -0
- package/dist/routes/apps.js +64 -0
- package/dist/routes/apps.js.map +1 -0
- package/dist/routes/session-start.d.ts +59 -0
- package/dist/routes/session-start.d.ts.map +1 -0
- package/dist/routes/session-start.js +129 -0
- package/dist/routes/session-start.js.map +1 -0
- package/dist/routes/sessions.d.ts +2 -7
- package/dist/routes/sessions.d.ts.map +1 -1
- package/dist/routes/sessions.js +28 -175
- package/dist/routes/sessions.js.map +1 -1
- package/dist/server.d.ts +2 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +8 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/backend.ts +1 -1
- package/src/contract.ts +5 -12
- package/src/index.ts +1 -0
- package/src/routes/apps.ts +106 -0
- package/src/routes/session-start.ts +174 -0
- package/src/routes/sessions.ts +34 -196
- package/src/server.ts +12 -2
|
@@ -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
|
+
}
|
package/src/routes/sessions.ts
CHANGED
|
@@ -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 {
|
|
16
|
-
import {
|
|
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
|
-
|
|
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
|
|
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
|
|
239
|
-
reply.code(
|
|
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
|
-
//
|
|
246
|
-
//
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
157
|
+
return startWorkloadSession(
|
|
158
|
+
app,
|
|
159
|
+
deps,
|
|
160
|
+
{
|
|
161
|
+
bundle: body.bundle,
|
|
285
162
|
entryRelativePath: entryRelative,
|
|
286
|
-
env:
|
|
163
|
+
env: body.env,
|
|
287
164
|
ports: body.ports ?? [],
|
|
288
|
-
config,
|
|
289
|
-
selfContained:
|
|
165
|
+
config: body.config,
|
|
166
|
+
selfContained: false,
|
|
290
167
|
inspect: body.inspect ?? false,
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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;
|
|
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
|
-
//
|
|
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
|
}),
|