@telorun/k8s-runner 0.13.0 → 0.15.0
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/README.md +166 -107
- package/dist/bundle-store.d.ts +6 -13
- package/dist/bundle-store.d.ts.map +1 -1
- package/dist/bundle-store.js +6 -18
- package/dist/bundle-store.js.map +1 -1
- package/dist/capabilities.d.ts +3 -3
- package/dist/capabilities.js +4 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/config.d.ts +47 -37
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +64 -25
- package/dist/config.js.map +1 -1
- package/dist/k8s/api-error.d.ts +34 -0
- package/dist/k8s/api-error.d.ts.map +1 -0
- package/dist/k8s/api-error.js +118 -0
- package/dist/k8s/api-error.js.map +1 -0
- package/dist/k8s/backend.d.ts +4 -0
- package/dist/k8s/backend.d.ts.map +1 -1
- package/dist/k8s/backend.js +77 -51
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/client.d.ts +7 -2
- package/dist/k8s/client.d.ts.map +1 -1
- package/dist/k8s/client.js +3 -2
- package/dist/k8s/client.js.map +1 -1
- package/dist/k8s/pod-spec.d.ts +8 -9
- package/dist/k8s/pod-spec.d.ts.map +1 -1
- package/dist/k8s/pod-spec.js +19 -29
- package/dist/k8s/pod-spec.js.map +1 -1
- package/dist/k8s/pod-status.d.ts +5 -0
- package/dist/k8s/pod-status.d.ts.map +1 -1
- package/dist/k8s/pod-status.js +9 -2
- package/dist/k8s/pod-status.js.map +1 -1
- package/dist/k8s/routing/create-or-replace.d.ts +5 -0
- package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
- package/dist/k8s/routing/create-or-replace.js +15 -0
- package/dist/k8s/routing/create-or-replace.js.map +1 -0
- package/dist/k8s/routing/gateway-router.d.ts +5 -0
- package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
- package/dist/k8s/routing/gateway-router.js +178 -0
- package/dist/k8s/routing/gateway-router.js.map +1 -0
- package/dist/k8s/routing/index.d.ts +24 -0
- package/dist/k8s/routing/index.d.ts.map +1 -0
- package/dist/k8s/routing/index.js +27 -0
- package/dist/k8s/routing/index.js.map +1 -0
- package/dist/k8s/routing/ingress-router.d.ts +13 -0
- package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
- package/dist/k8s/routing/ingress-router.js +125 -0
- package/dist/k8s/routing/ingress-router.js.map +1 -0
- package/dist/k8s/routing/route-health.d.ts +34 -0
- package/dist/k8s/routing/route-health.d.ts.map +1 -0
- package/dist/k8s/routing/route-health.js +77 -0
- package/dist/k8s/routing/route-health.js.map +1 -0
- package/dist/k8s/routing/routing-mode.d.ts +45 -0
- package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
- package/dist/k8s/routing/routing-mode.js +141 -0
- package/dist/k8s/routing/routing-mode.js.map +1 -0
- package/dist/k8s/routing/session-endpoints.d.ts +25 -0
- package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
- package/dist/k8s/routing/session-endpoints.js +36 -0
- package/dist/k8s/routing/session-endpoints.js.map +1 -0
- package/dist/k8s/routing/session-router.d.ts +57 -0
- package/dist/k8s/routing/session-router.d.ts.map +1 -0
- package/dist/k8s/routing/session-router.js +2 -0
- package/dist/k8s/routing/session-router.js.map +1 -0
- package/dist/k8s/routing/session-service.d.ts +14 -0
- package/dist/k8s/routing/session-service.d.ts.map +1 -0
- package/dist/k8s/routing/session-service.js +42 -0
- package/dist/k8s/routing/session-service.js.map +1 -0
- package/dist/k8s/watch-session.d.ts +7 -5
- package/dist/k8s/watch-session.d.ts.map +1 -1
- package/dist/k8s/watch-session.js +90 -83
- package/dist/k8s/watch-session.js.map +1 -1
- package/dist/k8s/workspace-configmap.d.ts.map +1 -1
- package/dist/k8s/workspace-configmap.js +3 -12
- package/dist/k8s/workspace-configmap.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +37 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/bundle-store.ts +6 -19
- package/src/capabilities.ts +4 -4
- package/src/config.ts +130 -68
- package/src/k8s/api-error.test.ts +89 -0
- package/src/k8s/api-error.ts +128 -0
- package/src/k8s/backend.ts +103 -58
- package/src/k8s/client.ts +10 -3
- package/src/k8s/pod-spec.test.ts +57 -2
- package/src/k8s/pod-spec.ts +25 -36
- package/src/k8s/pod-status.ts +10 -2
- package/src/k8s/routing/create-or-replace.ts +16 -0
- package/src/k8s/routing/gateway-router.test.ts +236 -0
- package/src/k8s/routing/gateway-router.ts +219 -0
- package/src/k8s/routing/index.ts +44 -0
- package/src/k8s/routing/ingress-router.test.ts +236 -0
- package/src/k8s/routing/ingress-router.ts +157 -0
- package/src/k8s/routing/route-health.test.ts +215 -0
- package/src/k8s/routing/route-health.ts +95 -0
- package/src/k8s/routing/routing-mode.test.ts +218 -0
- package/src/k8s/routing/routing-mode.ts +191 -0
- package/src/k8s/routing/session-endpoints.ts +45 -0
- package/src/k8s/routing/session-router.ts +59 -0
- package/src/k8s/routing/session-service.ts +55 -0
- package/src/k8s/watch-pod-spec.test.ts +0 -1
- package/src/k8s/watch-session.ts +90 -77
- package/src/k8s/workspace-configmap.ts +3 -15
- package/src/server.ts +41 -4
- package/dist/k8s/image-build.d.ts +0 -103
- package/dist/k8s/image-build.d.ts.map +0 -1
- package/dist/k8s/image-build.js +0 -427
- package/dist/k8s/image-build.js.map +0 -1
- package/dist/k8s/ingress.d.ts +0 -15
- package/dist/k8s/ingress.d.ts.map +0 -1
- package/dist/k8s/ingress.js +0 -102
- package/dist/k8s/ingress.js.map +0 -1
- package/src/k8s/image-build.test.ts +0 -238
- package/src/k8s/image-build.ts +0 -540
- package/src/k8s/ingress.test.ts +0 -146
- package/src/k8s/ingress.ts +0 -127
package/src/k8s/backend.ts
CHANGED
|
@@ -15,9 +15,16 @@ import { relayDebugStream, SessionStartError, watchReachability } from "@telorun
|
|
|
15
15
|
import type { BundleStore } from "../bundle-store.js";
|
|
16
16
|
import type { K8sRunnerConfig } from "../config.js";
|
|
17
17
|
import { clampLimits } from "../limits.js";
|
|
18
|
+
import { apiFailure, apiReason, withCause } from "./api-error.js";
|
|
18
19
|
import type { KubeClient } from "./client.js";
|
|
19
|
-
import {
|
|
20
|
-
|
|
20
|
+
import {
|
|
21
|
+
buildSessionService,
|
|
22
|
+
endpointsFor,
|
|
23
|
+
hostForPort,
|
|
24
|
+
watchRouteHealth,
|
|
25
|
+
type PublishedRoute,
|
|
26
|
+
type SessionRouter,
|
|
27
|
+
} from "./routing/index.js";
|
|
21
28
|
import { buildAppPod, buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
|
|
22
29
|
import {
|
|
23
30
|
deletePod,
|
|
@@ -50,10 +57,13 @@ export interface K8sBackendDeps {
|
|
|
50
57
|
kube: KubeClient;
|
|
51
58
|
config: K8sRunnerConfig;
|
|
52
59
|
bundleStore: BundleStore;
|
|
60
|
+
/** How this cluster publishes session routes, resolved once at boot. Absent →
|
|
61
|
+
* logs-only: no base domain, or `SESSION_ROUTING_MODE=none`. */
|
|
62
|
+
router?: SessionRouter;
|
|
53
63
|
}
|
|
54
64
|
|
|
55
65
|
export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
56
|
-
const { kube, config, bundleStore } = deps;
|
|
66
|
+
const { kube, config, bundleStore, router } = deps;
|
|
57
67
|
const ns = config.sessionNamespace;
|
|
58
68
|
|
|
59
69
|
async function probe(probeConfig: ProbeConfig): Promise<AvailabilityReport> {
|
|
@@ -80,11 +90,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
80
90
|
async function start(spec: BackendStartSpec): Promise<BackendSession> {
|
|
81
91
|
// A watch session is a different pod shape and a different lifetime — it
|
|
82
92
|
// outlives its runs — so it takes its own path rather than accreting
|
|
83
|
-
// branches through this one.
|
|
84
|
-
// exists to put a dependency closure on disk before boot, and a watch
|
|
85
|
-
// session fetches its own and keeps it for the pod's life.
|
|
93
|
+
// branches through this one.
|
|
86
94
|
if (spec.mode === "watch") {
|
|
87
|
-
return startWatchSession({ kube, config }, spec);
|
|
95
|
+
return startWatchSession({ kube, config, router }, spec);
|
|
88
96
|
}
|
|
89
97
|
return startRunSession(spec);
|
|
90
98
|
}
|
|
@@ -104,9 +112,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
104
112
|
|
|
105
113
|
let pod: V1Pod;
|
|
106
114
|
if (spec.selfContained) {
|
|
107
|
-
// Operator-predefined app (catalog image): self-contained, no
|
|
108
|
-
//
|
|
109
|
-
//
|
|
115
|
+
// Operator-predefined app (catalog image): self-contained, with no bundle
|
|
116
|
+
// to deliver — the pod runs the image's own entrypoint with the env the
|
|
117
|
+
// core route already merged.
|
|
110
118
|
pod = buildAppPod({
|
|
111
119
|
config,
|
|
112
120
|
sessionId: spec.sessionId,
|
|
@@ -118,30 +126,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
118
126
|
pullPolicy: spec.config.pullPolicy,
|
|
119
127
|
});
|
|
120
128
|
} else {
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// registry can't stall a session. Throws SessionStartError on a build
|
|
125
|
-
// failure, carrying the build pod's log tail.
|
|
126
|
-
const image = await ensureSessionImage(
|
|
127
|
-
{
|
|
128
|
-
kube,
|
|
129
|
-
build: config.build,
|
|
130
|
-
bundleStore,
|
|
131
|
-
initImage: config.initImage,
|
|
132
|
-
managedByLabel: config.managedByLabel,
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
bundle: spec.bundle,
|
|
136
|
-
entryRelativePath: app.entryRelativePath,
|
|
137
|
-
baseImage: spec.config.image || config.defaultImage,
|
|
138
|
-
pullPolicy: spec.config.pullPolicy,
|
|
139
|
-
onProgress: (message, done) => spec.onProgress("build", message, done, appName),
|
|
140
|
-
},
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
// The image is keyed on the dependency closure only, so deliver the
|
|
144
|
-
// per-session body to the Pod's /app at boot via a tokenized, single-use URL.
|
|
129
|
+
// Deliver the body to the Pod's /app at boot via a tokenized, single-use
|
|
130
|
+
// URL, and run it on the plain kernel image: the kernel resolves its own
|
|
131
|
+
// module closure into the pod's cache emptyDir on the way up.
|
|
145
132
|
const bundleUrl = await bundleStore.stageSessionBundle(spec.sessionId, spec.bundle);
|
|
146
133
|
|
|
147
134
|
pod = buildSessionPod({
|
|
@@ -152,7 +139,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
152
139
|
env: spec.env,
|
|
153
140
|
ports: app.ports,
|
|
154
141
|
limits,
|
|
155
|
-
image,
|
|
142
|
+
image: spec.config.image || config.defaultImage,
|
|
143
|
+
pullPolicy: spec.config.pullPolicy,
|
|
156
144
|
bundleUrl,
|
|
157
145
|
inspect: spec.inspect,
|
|
158
146
|
});
|
|
@@ -163,7 +151,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
163
151
|
const created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
|
|
164
152
|
podUid = created.metadata?.uid ?? "";
|
|
165
153
|
} catch (err) {
|
|
166
|
-
throw
|
|
154
|
+
throw apiFailure(err, "create", "could not create the session pod");
|
|
167
155
|
}
|
|
168
156
|
|
|
169
157
|
let finished = false;
|
|
@@ -177,7 +165,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
177
165
|
let startDeadline: NodeJS.Timeout | undefined;
|
|
178
166
|
let podIP: string | undefined;
|
|
179
167
|
const debugAbort = new AbortController();
|
|
180
|
-
|
|
168
|
+
// Aborts every background watcher this session started: the per-port
|
|
169
|
+
// reachability probes and the route-health watch.
|
|
170
|
+
const watchersAbort = new AbortController();
|
|
181
171
|
|
|
182
172
|
const stdin = new PassThrough();
|
|
183
173
|
const stdout = new Writable({
|
|
@@ -200,7 +190,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
200
190
|
clearStartDeadline();
|
|
201
191
|
abortWatch();
|
|
202
192
|
debugAbort.abort();
|
|
203
|
-
|
|
193
|
+
watchersAbort.abort();
|
|
204
194
|
bundleStore.drop(spec.sessionId);
|
|
205
195
|
spec.onStatus(status);
|
|
206
196
|
try {
|
|
@@ -214,12 +204,12 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
214
204
|
// Flip the session to `running` when the pod reaches Running. Deterministic
|
|
215
205
|
// and independent of the session image's telo version — a readiness signal
|
|
216
206
|
// would couple this to the in-image CLI (and a stale base image would never
|
|
217
|
-
// flip). The
|
|
218
|
-
//
|
|
207
|
+
// flip). The provision progress (streamed before this) covers the slow part;
|
|
208
|
+
// module resolution and validation run while already `running`.
|
|
219
209
|
const flipRunning = (): void => {
|
|
220
210
|
if (readyFlipped || finished) return;
|
|
221
211
|
readyFlipped = true;
|
|
222
|
-
spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, app.ports) });
|
|
212
|
+
spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, app.ports, Boolean(router)) });
|
|
223
213
|
};
|
|
224
214
|
|
|
225
215
|
let resolveRunning!: () => void;
|
|
@@ -301,7 +291,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
301
291
|
} catch (err) {
|
|
302
292
|
if (!runningSeen) {
|
|
303
293
|
clearStartDeadline();
|
|
304
|
-
rejectRunning(new Error(`
|
|
294
|
+
rejectRunning(new Error(`could not watch the session pod: ${apiReason(err)}`, { cause: err }));
|
|
305
295
|
} else if (!finished) {
|
|
306
296
|
setTimeout(() => void armWatch(), WATCH_REARM_DELAY_MS).unref?.();
|
|
307
297
|
}
|
|
@@ -324,7 +314,16 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
324
314
|
abortWatch();
|
|
325
315
|
bundleStore.drop(spec.sessionId);
|
|
326
316
|
await deletePod(kube, ns, podName);
|
|
327
|
-
|
|
317
|
+
// `err` is one of OUR errors — a pod-status failure message, a
|
|
318
|
+
// disappeared pod, the start deadline, a watch that could not be armed —
|
|
319
|
+
// so its message is already client-safe and stands. What must not be lost
|
|
320
|
+
// is its `cause`: the watch path attaches the raw ApiException there, and
|
|
321
|
+
// rewrapping without forwarding it drops the very detail the summarized
|
|
322
|
+
// message exists to relocate into the log.
|
|
323
|
+
throw withCause(
|
|
324
|
+
new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`),
|
|
325
|
+
(err as { cause?: unknown })?.cause ?? err,
|
|
326
|
+
);
|
|
328
327
|
}
|
|
329
328
|
|
|
330
329
|
// Attach a PTY to the running container: stdout → onOutput, stdin ← writes.
|
|
@@ -333,12 +332,59 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
333
332
|
socket = ws as unknown as ResizableSocket;
|
|
334
333
|
} catch (err) {
|
|
335
334
|
// Attach failure isn't fatal — status still flows; surface the degraded PTY.
|
|
336
|
-
spec.onOutput(
|
|
335
|
+
spec.onOutput(
|
|
336
|
+
appName,
|
|
337
|
+
Buffer.from(`\r\n[runner] failed to attach PTY: ${apiReason(err)}\r\n`),
|
|
338
|
+
"tty",
|
|
339
|
+
);
|
|
337
340
|
}
|
|
338
341
|
|
|
339
|
-
if (
|
|
340
|
-
|
|
341
|
-
|
|
342
|
+
if (router && app.ports.length > 0) {
|
|
343
|
+
let published: PublishedRoute[] = [];
|
|
344
|
+
try {
|
|
345
|
+
published = await publishRoutes(
|
|
346
|
+
deps,
|
|
347
|
+
router,
|
|
348
|
+
spec.sessionId,
|
|
349
|
+
podName,
|
|
350
|
+
podUid,
|
|
351
|
+
app.ports,
|
|
352
|
+
);
|
|
353
|
+
} catch (err) {
|
|
354
|
+
// A publish failure is reported on the ROUTE channel, not only as a
|
|
355
|
+
// terminal line: the endpoints were already advertised on the `running`
|
|
356
|
+
// status, so leaving this to a log line is the silence this whole change
|
|
357
|
+
// exists to remove.
|
|
358
|
+
const reason = `could not publish the session route: ${apiReason(err)}`;
|
|
359
|
+
for (const p of app.ports) {
|
|
360
|
+
if (p.protocol !== "tcp") continue;
|
|
361
|
+
spec.onRoute(appName, {
|
|
362
|
+
host: hostForPort(config, spec.sessionId, p.port),
|
|
363
|
+
port: p.port,
|
|
364
|
+
state: "unprogrammed",
|
|
365
|
+
reason,
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
spec.onOutput(appName, Buffer.from(`\r\n[runner] ${reason}\r\n`), "tty");
|
|
369
|
+
}
|
|
370
|
+
// Verify the routing layer actually PROGRAMMED what we published. Without
|
|
371
|
+
// this a cluster whose routing objects nothing reconciles is indistinguishable
|
|
372
|
+
// from a healthy one: reachability below dials the pod directly and stays
|
|
373
|
+
// green while every public URL 404s.
|
|
374
|
+
void watchRouteHealth({
|
|
375
|
+
router,
|
|
376
|
+
sessionId: spec.sessionId,
|
|
377
|
+
routes: published,
|
|
378
|
+
onState: (route, state, reason) =>
|
|
379
|
+
spec.onRoute(appName, { host: route.host, port: route.port, state, reason }),
|
|
380
|
+
onReadError: (err) =>
|
|
381
|
+
spec.onOutput(
|
|
382
|
+
appName,
|
|
383
|
+
Buffer.from(`\r\n[runner] could not read route status: ${apiReason(err)}\r\n`),
|
|
384
|
+
"tty",
|
|
385
|
+
),
|
|
386
|
+
signal: watchersAbort.signal,
|
|
387
|
+
timeoutMs: config.sessionRouting.routeReadyTimeoutMs,
|
|
342
388
|
});
|
|
343
389
|
}
|
|
344
390
|
|
|
@@ -377,7 +423,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
377
423
|
if (tcpPorts.length > 0) {
|
|
378
424
|
void (async () => {
|
|
379
425
|
const ip = await resolvePodIP();
|
|
380
|
-
if (
|
|
426
|
+
if (watchersAbort.signal.aborted) return;
|
|
381
427
|
if (!ip) {
|
|
382
428
|
// Couldn't resolve the pod IP to probe — report unverified rather than
|
|
383
429
|
// leaving the badge spinning forever.
|
|
@@ -388,7 +434,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
388
434
|
host: ip,
|
|
389
435
|
ports: tcpPorts,
|
|
390
436
|
onState: (port, state) => spec.onReachability(appName, port, state),
|
|
391
|
-
signal:
|
|
437
|
+
signal: watchersAbort.signal,
|
|
392
438
|
});
|
|
393
439
|
})();
|
|
394
440
|
}
|
|
@@ -448,28 +494,27 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
448
494
|
return { probe, start, reapOrphans };
|
|
449
495
|
}
|
|
450
496
|
|
|
451
|
-
|
|
497
|
+
/** Create the session Service and publish its routes on whichever layer the
|
|
498
|
+
* cluster resolved to. Returns the hosts published, for the route watch. */
|
|
499
|
+
async function publishRoutes(
|
|
452
500
|
deps: K8sBackendDeps,
|
|
501
|
+
router: SessionRouter,
|
|
453
502
|
sessionId: string,
|
|
454
503
|
podName: string,
|
|
455
504
|
podUid: string,
|
|
456
505
|
ports: PortMapping[],
|
|
457
|
-
): Promise<
|
|
506
|
+
): Promise<PublishedRoute[]> {
|
|
458
507
|
const { kube, config } = deps;
|
|
459
508
|
const ns = config.sessionNamespace;
|
|
460
509
|
const service = buildSessionService(config, sessionId, podName, podUid, ports);
|
|
461
510
|
await kube.core.createNamespacedService({ namespace: ns, body: service });
|
|
462
|
-
|
|
463
|
-
config,
|
|
511
|
+
return router.publish({
|
|
464
512
|
sessionId,
|
|
465
|
-
service.metadata!.name!,
|
|
513
|
+
serviceName: service.metadata!.name!,
|
|
466
514
|
podName,
|
|
467
515
|
podUid,
|
|
468
516
|
ports,
|
|
469
|
-
);
|
|
470
|
-
// No tcp ports → no HTTP-routable rules; the Service still exists for any udp.
|
|
471
|
-
if (!ingress.spec?.rules?.length) return;
|
|
472
|
-
await kube.networking.createNamespacedIngress({ namespace: ns, body: ingress });
|
|
517
|
+
});
|
|
473
518
|
}
|
|
474
519
|
|
|
475
520
|
async function clusterReachable(kube: KubeClient): Promise<boolean> {
|
package/src/k8s/client.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import {
|
|
2
|
+
ApisApi,
|
|
2
3
|
Attach,
|
|
3
|
-
BatchV1Api,
|
|
4
4
|
CoreV1Api,
|
|
5
|
+
CustomObjectsApi,
|
|
5
6
|
KubeConfig,
|
|
6
7
|
NetworkingV1Api,
|
|
7
8
|
Watch,
|
|
@@ -10,8 +11,13 @@ import {
|
|
|
10
11
|
export interface KubeClient {
|
|
11
12
|
kc: KubeConfig;
|
|
12
13
|
core: CoreV1Api;
|
|
13
|
-
batch: BatchV1Api;
|
|
14
14
|
networking: NetworkingV1Api;
|
|
15
|
+
/** Gateway API has no typed client in `@kubernetes/client-node`, so HTTPRoutes
|
|
16
|
+
* are read and written as unstructured custom objects. */
|
|
17
|
+
custom: CustomObjectsApi;
|
|
18
|
+
/** API-group discovery, used to decide whether Gateway API is served at all
|
|
19
|
+
* when the routing mode is `auto`. */
|
|
20
|
+
apis: ApisApi;
|
|
15
21
|
attach: Attach;
|
|
16
22
|
watch: Watch;
|
|
17
23
|
}
|
|
@@ -31,8 +37,9 @@ export function createKubeClient(): KubeClient {
|
|
|
31
37
|
return {
|
|
32
38
|
kc,
|
|
33
39
|
core: kc.makeApiClient(CoreV1Api),
|
|
34
|
-
batch: kc.makeApiClient(BatchV1Api),
|
|
35
40
|
networking: kc.makeApiClient(NetworkingV1Api),
|
|
41
|
+
custom: kc.makeApiClient(CustomObjectsApi),
|
|
42
|
+
apis: kc.makeApiClient(ApisApi),
|
|
36
43
|
attach: new Attach(kc),
|
|
37
44
|
watch: new Watch(kc),
|
|
38
45
|
};
|
package/src/k8s/pod-spec.test.ts
CHANGED
|
@@ -1,13 +1,68 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { loadK8sRunnerConfig } from "../config.js";
|
|
4
|
-
import { buildAppPod } from "./pod-spec.js";
|
|
4
|
+
import { buildAppPod, buildSessionPod } from "./pod-spec.js";
|
|
5
5
|
|
|
6
6
|
const BASE_ENV = {
|
|
7
7
|
RUNNER_SELF_URL: "http://k8s-runner.telo-runner.svc:8062",
|
|
8
|
-
RUNNER_IMAGE_REPOSITORY: "registry.telo-runner.svc:5000/telo-sessions",
|
|
9
8
|
};
|
|
10
9
|
|
|
10
|
+
describe("buildSessionPod", () => {
|
|
11
|
+
const config = loadK8sRunnerConfig({ ...process.env, ...BASE_ENV });
|
|
12
|
+
const pod = buildSessionPod({
|
|
13
|
+
config,
|
|
14
|
+
sessionId: "abc123",
|
|
15
|
+
podName: "telo-run-abc123",
|
|
16
|
+
entryRelativePath: "telo.yaml",
|
|
17
|
+
env: { API_TOKEN: "tok" },
|
|
18
|
+
ports: [{ port: 3000, protocol: "tcp" }],
|
|
19
|
+
limits: config.limits,
|
|
20
|
+
image: "telorun/node:0.30.1-slim",
|
|
21
|
+
pullPolicy: "always",
|
|
22
|
+
bundleUrl: "http://k8s-runner.telo-runner.svc:8062/internal/bundles/abc123?token=t",
|
|
23
|
+
inspect: false,
|
|
24
|
+
});
|
|
25
|
+
const container = pod.spec!.containers[0];
|
|
26
|
+
|
|
27
|
+
it("runs the picked kernel image under the requested pull policy", () => {
|
|
28
|
+
expect(container.image).toBe("telorun/node:0.30.1-slim");
|
|
29
|
+
expect(container.imagePullPolicy).toBe("Always");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("resolves the module closure at boot into a writable cache", () => {
|
|
33
|
+
// No prebuilt image means no baked deps: the cache has to be a mounted
|
|
34
|
+
// emptyDir, and `telo run` has to be allowed to write it.
|
|
35
|
+
expect(container.env).toContainEqual({ name: "TELO_CACHE_DIR", value: "/telo-cache" });
|
|
36
|
+
expect(container.volumeMounts).toContainEqual({
|
|
37
|
+
name: "telo-cache",
|
|
38
|
+
mountPath: "/telo-cache",
|
|
39
|
+
});
|
|
40
|
+
expect(pod.spec!.volumes).toContainEqual({ name: "telo-cache", emptyDir: {} });
|
|
41
|
+
expect(container.command).toEqual(["telo", "run", "/app/telo.yaml"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is given headroom to resolve a closure, not just to run one", () => {
|
|
45
|
+
// The ceilings used to describe a pod that only RAN a prebuilt image (50m /
|
|
46
|
+
// 100Mi / 512Mi). It now downloads, unpacks and resolves the closure itself,
|
|
47
|
+
// into an emptyDir charged against ephemeral-storage — the old numbers were
|
|
48
|
+
// an OOMKill and an eviction for the ordinary case. Asserted against the
|
|
49
|
+
// app-session ceilings, which is the tier the equivalent watch container
|
|
50
|
+
// already ran under.
|
|
51
|
+
expect(container.resources?.limits?.memory).toBe(config.appLimits.memory);
|
|
52
|
+
expect(container.resources?.limits?.cpu).toBe(config.appLimits.cpu);
|
|
53
|
+
expect(container.resources?.limits?.["ephemeral-storage"]).toBe(
|
|
54
|
+
config.appLimits.ephemeralStorage,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("delivers the body over the initContainer and keeps the rootfs read-only", () => {
|
|
59
|
+
const init = pod.spec!.initContainers![0];
|
|
60
|
+
expect(init.args?.[0]).toContain("/internal/bundles/abc123?token=t");
|
|
61
|
+
expect(container.securityContext).toMatchObject({ readOnlyRootFilesystem: true });
|
|
62
|
+
expect(pod.spec!.automountServiceAccountToken).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
11
66
|
describe("buildAppPod", () => {
|
|
12
67
|
const config = loadK8sRunnerConfig({ ...process.env, ...BASE_ENV });
|
|
13
68
|
const pod = buildAppPod({
|
package/src/k8s/pod-spec.ts
CHANGED
|
@@ -17,11 +17,11 @@ export interface BuildPodArgs {
|
|
|
17
17
|
env: Record<string, string>;
|
|
18
18
|
ports: PortMapping[];
|
|
19
19
|
limits: ResolvedLimits;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* is NOT baked — it's delivered to `/app` at boot via the initContainer. */
|
|
20
|
+
/** The kernel image to run (`telorun/node`) — the session's picked base image,
|
|
21
|
+
* or the runner's default. Nothing is baked per app: the body arrives over the
|
|
22
|
+
* initContainer and the kernel resolves its module closure at boot. */
|
|
24
23
|
image: string;
|
|
24
|
+
pullPolicy: PullPolicy;
|
|
25
25
|
/** Tokenized, single-use URL the body-delivery initContainer fetches the
|
|
26
26
|
* session bundle tarball from (`BundleStore.stageSessionBundle`). */
|
|
27
27
|
bundleUrl: string;
|
|
@@ -38,12 +38,11 @@ export const INSPECT_PORT = 9230;
|
|
|
38
38
|
|
|
39
39
|
const APP_DIR = "/app";
|
|
40
40
|
const WORK_DIR = "/work";
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
/** The kernel's module cache for this session. A writable emptyDir: the closure
|
|
42
|
+
* is resolved at boot and lives as long as the pod, so a re-run of the same app
|
|
43
|
+
* is a fresh download — the price of having no prebuilt image. */
|
|
44
44
|
const DEPS_DIR = "/telo-cache";
|
|
45
|
-
/** Writable HOME / npm scratch under a read-only rootfs.
|
|
46
|
-
* (which is now read-only baked deps, not scratch). */
|
|
45
|
+
/** Writable HOME / npm scratch under a read-only rootfs. */
|
|
47
46
|
const HOME_DIR = "/home/telo";
|
|
48
47
|
const TMP_MOUNT = "/tmp";
|
|
49
48
|
|
|
@@ -53,8 +52,8 @@ const TMP_MOUNT = "/tmp";
|
|
|
53
52
|
* seccomp RuntimeDefault); a sandbox RuntimeClass is layered on when configured.
|
|
54
53
|
*
|
|
55
54
|
* The body-delivery initContainer fetches the session bundle into the writable
|
|
56
|
-
* `/app` emptyDir; the session container runs `telo run /app/<entry
|
|
57
|
-
*
|
|
55
|
+
* `/app` emptyDir; the session container runs `telo run /app/<entry>` on the
|
|
56
|
+
* plain kernel image and resolves its module closure into `/telo-cache`.
|
|
58
57
|
* `readOnlyRootFilesystem` stays on — every write lands on a mounted emptyDir.
|
|
59
58
|
*/
|
|
60
59
|
export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
@@ -73,8 +72,8 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
const envVars = Object.entries(args.env).map(([name, value]) => ({ name, value }));
|
|
76
|
-
//
|
|
77
|
-
// separate
|
|
75
|
+
// Resolve deps into the session's own `/telo-cache` emptyDir; keep HOME/npm
|
|
76
|
+
// scratch on a separate one under the read-only root filesystem.
|
|
78
77
|
envVars.push({ name: "TELO_CACHE_DIR", value: DEPS_DIR });
|
|
79
78
|
envVars.push({ name: "HOME", value: HOME_DIR });
|
|
80
79
|
envVars.push({ name: "npm_config_cache", value: `${HOME_DIR}/.npm` });
|
|
@@ -95,11 +94,9 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
95
94
|
restartPolicy: "Never",
|
|
96
95
|
activeDeadlineSeconds: limits.ttlSeconds,
|
|
97
96
|
automountServiceAccountToken: false,
|
|
98
|
-
// Pull the
|
|
97
|
+
// Pull the kernel image from a private registry. The Secret must exist in
|
|
99
98
|
// the session namespace (pull secrets are namespace-scoped).
|
|
100
|
-
...(config.
|
|
101
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
102
|
-
: {}),
|
|
99
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
103
100
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
104
101
|
securityContext: {
|
|
105
102
|
runAsNonRoot: true,
|
|
@@ -110,8 +107,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
110
107
|
},
|
|
111
108
|
initContainers: [
|
|
112
109
|
{
|
|
113
|
-
// Deliver the
|
|
114
|
-
// image bakes only the dependency closure, so the body arrives here.
|
|
110
|
+
// Deliver the session body into the writable /app emptyDir.
|
|
115
111
|
name: "body-fetch",
|
|
116
112
|
image: config.initImage,
|
|
117
113
|
command: ["sh", "-c"],
|
|
@@ -129,13 +125,10 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
129
125
|
{
|
|
130
126
|
name: "session",
|
|
131
127
|
image: args.image,
|
|
132
|
-
|
|
133
|
-
// the
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
// the baked deps from TELO_CACHE_DIR and validates in-memory without
|
|
137
|
-
// touching the read-only cache. WORK_DIR is a writable emptyDir cwd so
|
|
138
|
-
// the workload's relative paths resolve under readOnlyRootFilesystem.
|
|
128
|
+
imagePullPolicy: pullPolicyToK8s(args.pullPolicy),
|
|
129
|
+
// Run the delivered body by absolute path. WORK_DIR is a writable
|
|
130
|
+
// emptyDir cwd so the workload's relative paths resolve under
|
|
131
|
+
// readOnlyRootFilesystem.
|
|
139
132
|
workingDir: WORK_DIR,
|
|
140
133
|
// 0.0.0.0 (not the CLI's loopback default) lets the runner reach the
|
|
141
134
|
// debug server across the pod network; the port is never published.
|
|
@@ -143,7 +136,6 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
143
136
|
"telo",
|
|
144
137
|
"run",
|
|
145
138
|
`${APP_DIR}/${args.entryRelativePath}`,
|
|
146
|
-
"--no-cache-write",
|
|
147
139
|
...(args.inspect ? ["--inspect", `0.0.0.0:${INSPECT_PORT}`, "--no-open"] : []),
|
|
148
140
|
],
|
|
149
141
|
env: envVars,
|
|
@@ -156,6 +148,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
156
148
|
resources,
|
|
157
149
|
volumeMounts: [
|
|
158
150
|
{ name: "app", mountPath: APP_DIR },
|
|
151
|
+
{ name: "telo-cache", mountPath: DEPS_DIR },
|
|
159
152
|
{ name: "work", mountPath: WORK_DIR },
|
|
160
153
|
{ name: "home", mountPath: HOME_DIR },
|
|
161
154
|
{ name: "tmp", mountPath: TMP_MOUNT },
|
|
@@ -165,6 +158,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
165
158
|
],
|
|
166
159
|
volumes: [
|
|
167
160
|
{ name: "app", emptyDir: {} },
|
|
161
|
+
{ name: "telo-cache", emptyDir: {} },
|
|
168
162
|
{ name: "work", emptyDir: {} },
|
|
169
163
|
{ name: "home", emptyDir: {} },
|
|
170
164
|
{ name: "tmp", emptyDir: {} },
|
|
@@ -225,9 +219,7 @@ export function buildAppPod(args: BuildAppPodArgs): V1Pod {
|
|
|
225
219
|
restartPolicy: "Never",
|
|
226
220
|
activeDeadlineSeconds: limits.ttlSeconds,
|
|
227
221
|
automountServiceAccountToken: false,
|
|
228
|
-
...(config.
|
|
229
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
230
|
-
: {}),
|
|
222
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
231
223
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
232
224
|
securityContext: {
|
|
233
225
|
seccompProfile: { type: "RuntimeDefault" },
|
|
@@ -326,9 +318,8 @@ export interface BuildWatchPodArgs {
|
|
|
326
318
|
agent?: ResolvedRunnerApp;
|
|
327
319
|
limits: ResolvedLimits;
|
|
328
320
|
/** Base image every app container and the workspace container run — the plain
|
|
329
|
-
* kernel image (`telorun/node`).
|
|
330
|
-
*
|
|
331
|
-
* fetches its own and keeps it for the pod's life. */
|
|
321
|
+
* kernel image (`telorun/node`). Each resolves its own module closure into
|
|
322
|
+
* the shared workspace volume, which lives as long as the pod. */
|
|
332
323
|
image: string;
|
|
333
324
|
pullPolicy: PullPolicy;
|
|
334
325
|
/** Name of the ConfigMap holding the workspace application's manifest. */
|
|
@@ -390,9 +381,7 @@ export function buildWatchPod(args: BuildWatchPodArgs): V1Pod {
|
|
|
390
381
|
// conversation mid-turn.
|
|
391
382
|
activeDeadlineSeconds: config.watch.maxTtlSeconds,
|
|
392
383
|
automountServiceAccountToken: false,
|
|
393
|
-
...(config.
|
|
394
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
395
|
-
: {}),
|
|
384
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
396
385
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
397
386
|
securityContext: {
|
|
398
387
|
runAsNonRoot: true,
|
package/src/k8s/pod-status.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { V1ContainerState, V1ContainerStatus, V1Pod } from "@kubernetes/client-node";
|
|
2
2
|
import type { RunStatus } from "@telorun/runner-core";
|
|
3
3
|
|
|
4
|
+
import { statusCode } from "./api-error.js";
|
|
4
5
|
import type { KubeClient } from "./client.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -128,8 +129,15 @@ export async function deletePod(kube: KubeClient, ns: string, name: string): Pro
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
export function is404(err: unknown): boolean {
|
|
131
|
-
|
|
132
|
-
|
|
132
|
+
return statusCode(err) === 404;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** A name-already-taken rejection — what makes a create idempotent by falling
|
|
136
|
+
* through to a replace. Reads the status through `statusCode` like every other
|
|
137
|
+
* classification here, so an `ECONNREFUSED` or a `DOMException`'s legacy number
|
|
138
|
+
* in `code` cannot be mistaken for one. */
|
|
139
|
+
export function isConflict(err: unknown): boolean {
|
|
140
|
+
return statusCode(err) === 409;
|
|
133
141
|
}
|
|
134
142
|
|
|
135
143
|
export function msg(err: unknown): string {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { isConflict } from "../pod-status.js";
|
|
2
|
+
|
|
3
|
+
/** A create that yields to an existing object of the same name. Every routing
|
|
4
|
+
* object is republished on reload (an app's declared port set changes), so the
|
|
5
|
+
* write has to be idempotent rather than create-once. */
|
|
6
|
+
export async function createOrReplace(
|
|
7
|
+
create: () => Promise<unknown>,
|
|
8
|
+
replace: () => Promise<unknown>,
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
try {
|
|
11
|
+
await create();
|
|
12
|
+
} catch (err) {
|
|
13
|
+
if (!isConflict(err)) throw err;
|
|
14
|
+
await replace();
|
|
15
|
+
}
|
|
16
|
+
}
|