@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.
Files changed (118) hide show
  1. package/README.md +166 -107
  2. package/dist/bundle-store.d.ts +6 -13
  3. package/dist/bundle-store.d.ts.map +1 -1
  4. package/dist/bundle-store.js +6 -18
  5. package/dist/bundle-store.js.map +1 -1
  6. package/dist/capabilities.d.ts +3 -3
  7. package/dist/capabilities.js +4 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/config.d.ts +47 -37
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +64 -25
  12. package/dist/config.js.map +1 -1
  13. package/dist/k8s/api-error.d.ts +34 -0
  14. package/dist/k8s/api-error.d.ts.map +1 -0
  15. package/dist/k8s/api-error.js +118 -0
  16. package/dist/k8s/api-error.js.map +1 -0
  17. package/dist/k8s/backend.d.ts +4 -0
  18. package/dist/k8s/backend.d.ts.map +1 -1
  19. package/dist/k8s/backend.js +77 -51
  20. package/dist/k8s/backend.js.map +1 -1
  21. package/dist/k8s/client.d.ts +7 -2
  22. package/dist/k8s/client.d.ts.map +1 -1
  23. package/dist/k8s/client.js +3 -2
  24. package/dist/k8s/client.js.map +1 -1
  25. package/dist/k8s/pod-spec.d.ts +8 -9
  26. package/dist/k8s/pod-spec.d.ts.map +1 -1
  27. package/dist/k8s/pod-spec.js +19 -29
  28. package/dist/k8s/pod-spec.js.map +1 -1
  29. package/dist/k8s/pod-status.d.ts +5 -0
  30. package/dist/k8s/pod-status.d.ts.map +1 -1
  31. package/dist/k8s/pod-status.js +9 -2
  32. package/dist/k8s/pod-status.js.map +1 -1
  33. package/dist/k8s/routing/create-or-replace.d.ts +5 -0
  34. package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
  35. package/dist/k8s/routing/create-or-replace.js +15 -0
  36. package/dist/k8s/routing/create-or-replace.js.map +1 -0
  37. package/dist/k8s/routing/gateway-router.d.ts +5 -0
  38. package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
  39. package/dist/k8s/routing/gateway-router.js +178 -0
  40. package/dist/k8s/routing/gateway-router.js.map +1 -0
  41. package/dist/k8s/routing/index.d.ts +24 -0
  42. package/dist/k8s/routing/index.d.ts.map +1 -0
  43. package/dist/k8s/routing/index.js +27 -0
  44. package/dist/k8s/routing/index.js.map +1 -0
  45. package/dist/k8s/routing/ingress-router.d.ts +13 -0
  46. package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
  47. package/dist/k8s/routing/ingress-router.js +125 -0
  48. package/dist/k8s/routing/ingress-router.js.map +1 -0
  49. package/dist/k8s/routing/route-health.d.ts +34 -0
  50. package/dist/k8s/routing/route-health.d.ts.map +1 -0
  51. package/dist/k8s/routing/route-health.js +77 -0
  52. package/dist/k8s/routing/route-health.js.map +1 -0
  53. package/dist/k8s/routing/routing-mode.d.ts +45 -0
  54. package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
  55. package/dist/k8s/routing/routing-mode.js +141 -0
  56. package/dist/k8s/routing/routing-mode.js.map +1 -0
  57. package/dist/k8s/routing/session-endpoints.d.ts +25 -0
  58. package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
  59. package/dist/k8s/routing/session-endpoints.js +36 -0
  60. package/dist/k8s/routing/session-endpoints.js.map +1 -0
  61. package/dist/k8s/routing/session-router.d.ts +57 -0
  62. package/dist/k8s/routing/session-router.d.ts.map +1 -0
  63. package/dist/k8s/routing/session-router.js +2 -0
  64. package/dist/k8s/routing/session-router.js.map +1 -0
  65. package/dist/k8s/routing/session-service.d.ts +14 -0
  66. package/dist/k8s/routing/session-service.d.ts.map +1 -0
  67. package/dist/k8s/routing/session-service.js +42 -0
  68. package/dist/k8s/routing/session-service.js.map +1 -0
  69. package/dist/k8s/watch-session.d.ts +7 -5
  70. package/dist/k8s/watch-session.d.ts.map +1 -1
  71. package/dist/k8s/watch-session.js +90 -83
  72. package/dist/k8s/watch-session.js.map +1 -1
  73. package/dist/k8s/workspace-configmap.d.ts.map +1 -1
  74. package/dist/k8s/workspace-configmap.js +3 -12
  75. package/dist/k8s/workspace-configmap.js.map +1 -1
  76. package/dist/server.d.ts.map +1 -1
  77. package/dist/server.js +37 -4
  78. package/dist/server.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/bundle-store.ts +6 -19
  81. package/src/capabilities.ts +4 -4
  82. package/src/config.ts +130 -68
  83. package/src/k8s/api-error.test.ts +89 -0
  84. package/src/k8s/api-error.ts +128 -0
  85. package/src/k8s/backend.ts +103 -58
  86. package/src/k8s/client.ts +10 -3
  87. package/src/k8s/pod-spec.test.ts +57 -2
  88. package/src/k8s/pod-spec.ts +25 -36
  89. package/src/k8s/pod-status.ts +10 -2
  90. package/src/k8s/routing/create-or-replace.ts +16 -0
  91. package/src/k8s/routing/gateway-router.test.ts +236 -0
  92. package/src/k8s/routing/gateway-router.ts +219 -0
  93. package/src/k8s/routing/index.ts +44 -0
  94. package/src/k8s/routing/ingress-router.test.ts +236 -0
  95. package/src/k8s/routing/ingress-router.ts +157 -0
  96. package/src/k8s/routing/route-health.test.ts +215 -0
  97. package/src/k8s/routing/route-health.ts +95 -0
  98. package/src/k8s/routing/routing-mode.test.ts +218 -0
  99. package/src/k8s/routing/routing-mode.ts +191 -0
  100. package/src/k8s/routing/session-endpoints.ts +45 -0
  101. package/src/k8s/routing/session-router.ts +59 -0
  102. package/src/k8s/routing/session-service.ts +55 -0
  103. package/src/k8s/watch-pod-spec.test.ts +0 -1
  104. package/src/k8s/watch-session.ts +90 -77
  105. package/src/k8s/workspace-configmap.ts +3 -15
  106. package/src/server.ts +41 -4
  107. package/dist/k8s/image-build.d.ts +0 -103
  108. package/dist/k8s/image-build.d.ts.map +0 -1
  109. package/dist/k8s/image-build.js +0 -427
  110. package/dist/k8s/image-build.js.map +0 -1
  111. package/dist/k8s/ingress.d.ts +0 -15
  112. package/dist/k8s/ingress.d.ts.map +0 -1
  113. package/dist/k8s/ingress.js +0 -102
  114. package/dist/k8s/ingress.js.map +0 -1
  115. package/src/k8s/image-build.test.ts +0 -238
  116. package/src/k8s/image-build.ts +0 -540
  117. package/src/k8s/ingress.test.ts +0 -146
  118. package/src/k8s/ingress.ts +0 -127
@@ -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 { ensureSessionImage } from "./image-build.js";
20
- import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
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. It also never builds an image: the build path
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 build and
108
- // no bundle to deliver — the pod runs the image's own entrypoint with
109
- // the env the core route already merged.
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
- // Prebuild a self-contained per-app image on-cluster (controllers + module
122
- // manifests baked in) and run it directly. Controller resolution never
123
- // happens on the session start path, so a slow or unreachable package
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 new SessionStartError("start_failed", "create", `failed to create pod: ${msg(err)}`, msg(err));
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
- const reachAbort = new AbortController();
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
- reachAbort.abort();
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 build/provision progress (streamed before this) covers the slow
218
- // part; the brief post-Running validation runs while already `running`.
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(`failed to watch pod: ${msg(err)}`));
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
- throw new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`, msg(err));
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(appName, Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`), "tty");
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 (config.sessionIngressBaseDomain && app.ports.length > 0) {
340
- await createIngress(deps, spec.sessionId, podName, podUid, app.ports).catch((err) => {
341
- spec.onOutput(appName, Buffer.from(`\r\n[runner] failed to create ingress: ${msg(err)}\r\n`), "tty");
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 (reachAbort.signal.aborted) return;
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: reachAbort.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
- async function createIngress(
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<void> {
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
- const { ingress } = buildSessionIngress(
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
  };
@@ -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({
@@ -17,11 +17,11 @@ export interface BuildPodArgs {
17
17
  env: Record<string, string>;
18
18
  ports: PortMapping[];
19
19
  limits: ResolvedLimits;
20
- /** Prebuilt per-app image to run. Controllers + module manifests are baked
21
- * into `/telo-cache/{manifests,npm}` (read-only) by the on-cluster build;
22
- * the image is keyed only on the dependency closure, so the per-session body
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
- /** Baked, read-only deps (`telo install` output). Set via TELO_CACHE_DIR, NOT
42
- * mounted it lives on the image rootfs, so `telo run --no-cache-write` reads
43
- * it without writing. */
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. Separate from DEPS_DIR
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
- * --no-cache-write` reading its deps from the baked, read-only `/telo-cache`.
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
- // Read deps from the baked, read-only `/telo-cache`; keep HOME/npm scratch on a
77
- // separate writable emptyDir under the read-only root filesystem.
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 per-app image from a private registry. The Secret must exist in
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.build.imagePullSecret
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 per-session body into the writable /app emptyDir. 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
- // The per-app tag is an immutable content hash, so IfNotPresent lets
133
- // the kubelet reuse a node-cached layer across runs of the same app.
134
- imagePullPolicy: "IfNotPresent",
135
- // Run the delivered body by absolute path; `--no-cache-write` reads
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.build.imagePullSecret
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`). A watch session never builds an image: the
330
- * build path exists to put a closure on disk before boot, and a watch session
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.build.imagePullSecret
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,
@@ -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
- const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
132
- return e?.statusCode === 404 || e?.code === 404 || e?.response?.statusCode === 404;
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
+ }