@telorun/k8s-runner 0.12.0 → 0.14.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 (57) hide show
  1. package/README.md +72 -93
  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 +7 -34
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +13 -22
  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.map +1 -1
  18. package/dist/k8s/backend.js +23 -35
  19. package/dist/k8s/backend.js.map +1 -1
  20. package/dist/k8s/client.d.ts +1 -2
  21. package/dist/k8s/client.d.ts.map +1 -1
  22. package/dist/k8s/client.js +1 -2
  23. package/dist/k8s/client.js.map +1 -1
  24. package/dist/k8s/pod-spec.d.ts +8 -9
  25. package/dist/k8s/pod-spec.d.ts.map +1 -1
  26. package/dist/k8s/pod-spec.js +19 -32
  27. package/dist/k8s/pod-spec.js.map +1 -1
  28. package/dist/k8s/pod-status.d.ts.map +1 -1
  29. package/dist/k8s/pod-status.js +2 -2
  30. package/dist/k8s/pod-status.js.map +1 -1
  31. package/dist/k8s/watch-session.d.ts +4 -5
  32. package/dist/k8s/watch-session.d.ts.map +1 -1
  33. package/dist/k8s/watch-session.js +8 -8
  34. package/dist/k8s/watch-session.js.map +1 -1
  35. package/dist/server.d.ts.map +1 -1
  36. package/dist/server.js +3 -4
  37. package/dist/server.js.map +1 -1
  38. package/package.json +2 -2
  39. package/src/bundle-store.ts +6 -19
  40. package/src/capabilities.ts +4 -4
  41. package/src/config.ts +20 -64
  42. package/src/k8s/api-error.test.ts +89 -0
  43. package/src/k8s/api-error.ts +128 -0
  44. package/src/k8s/backend.ts +34 -39
  45. package/src/k8s/client.ts +1 -10
  46. package/src/k8s/pod-spec.test.ts +58 -3
  47. package/src/k8s/pod-spec.ts +25 -39
  48. package/src/k8s/pod-status.ts +2 -2
  49. package/src/k8s/watch-pod-spec.test.ts +0 -1
  50. package/src/k8s/watch-session.ts +8 -13
  51. package/src/server.ts +3 -4
  52. package/dist/k8s/image-build.d.ts +0 -105
  53. package/dist/k8s/image-build.d.ts.map +0 -1
  54. package/dist/k8s/image-build.js +0 -432
  55. package/dist/k8s/image-build.js.map +0 -1
  56. package/src/k8s/image-build.test.ts +0 -244
  57. package/src/k8s/image-build.ts +0 -547
@@ -0,0 +1,128 @@
1
+ import { SessionStartError, type StartFailureStage } from "@telorun/runner-core";
2
+
3
+ /**
4
+ * A Kubernetes API rejection, phrased for the person who clicked Run.
5
+ *
6
+ * `ApiException.message` is a full HTTP dump — status line, the raw `Status`
7
+ * body and every response header — and a start failure's message travels
8
+ * verbatim to the client as the session's terminal `failed` status. That put
9
+ * the runner's ServiceAccount name, the request's audit id and its flowschema
10
+ * UIDs on an end user's screen, none of which they can act on.
11
+ *
12
+ * So the client sees the operation, the HTTP status and the API's own one-word
13
+ * `reason` — enough for an operator to know what was refused — and never the
14
+ * body or the headers. The raw exception rides along as the error's `cause`,
15
+ * which the runner's log serializer records, so nothing is swallowed: the detail
16
+ * moves to the log, it does not disappear.
17
+ */
18
+ export function apiFailure(
19
+ err: unknown,
20
+ stage: StartFailureStage,
21
+ action: string,
22
+ ): SessionStartError {
23
+ return withCause(
24
+ new SessionStartError("start_failed", stage, `${action}: ${apiReason(err)}`),
25
+ err,
26
+ );
27
+ }
28
+
29
+ /** Attach the original error as `cause` so a log serializer can reach it.
30
+ * Every wrap on a failure path goes through here — a rewrap that drops the
31
+ * cause silently undoes the whole point of summarizing the message. */
32
+ export function withCause<E extends Error>(error: E, cause: unknown): E {
33
+ error.cause = cause;
34
+ return error;
35
+ }
36
+
37
+ /** The client-safe half of a Kubernetes API error: what was refused, and who
38
+ * can act on it — never the `Status` message, which names cluster identities. */
39
+ export function apiReason(err: unknown): string {
40
+ const code = statusCode(err);
41
+ // No HTTP status means the request never got an answer: a refused connection,
42
+ // a DNS failure, an abort. Those carry a `code` too (`ECONNREFUSED`, an
43
+ // `ABORT_ERR` number), which is why `statusCode` accepts only what an HTTP
44
+ // response could have produced — reporting `HTTP ECONNREFUSED` claimed the
45
+ // apiserver rejected something when it was never reached, which is exactly
46
+ // the case where the difference matters.
47
+ if (code === undefined) return "the Kubernetes API could not be reached";
48
+
49
+ const reason = statusReason(err);
50
+ const detail = reason ? `${reason}, HTTP ${code}` : `HTTP ${code}`;
51
+ const cause = refusalCause(err);
52
+ if (cause === "quota") {
53
+ return `the session namespace's resource quota is exhausted (${detail})`;
54
+ }
55
+ if (cause === "rbac") {
56
+ return `the runner is not permitted to do this (${detail}). The cluster operator must check the runner's RBAC.`;
57
+ }
58
+ return `the Kubernetes API rejected the request (${detail})`;
59
+ }
60
+
61
+ /**
62
+ * Which of the two things a `403 Forbidden` means here.
63
+ *
64
+ * The status code alone cannot say: an RBAC denial and a `ResourceQuota`
65
+ * rejection are both `403` with `reason: Forbidden`, and this chart ships a
66
+ * quota (32 pods) enabled by default — so blaming RBAC on every 403 tells the
67
+ * operator to re-check a Role at exactly the moment the cluster is simply full.
68
+ * The two also differ in when they happen: an RBAC 403 is an install-time
69
+ * mistake, a quota 403 arrives under load.
70
+ *
71
+ * The only thing that separates them is the admission message, which is why it
72
+ * is READ here and never echoed: the classification crosses the boundary, the
73
+ * text does not. An unrecognized 403 degrades to the neutral wording rather than
74
+ * to a guess.
75
+ */
76
+ function refusalCause(err: unknown): "quota" | "rbac" | undefined {
77
+ const code = statusCode(err);
78
+ if (code !== 401 && code !== 403) return undefined;
79
+ const message = statusMessage(err);
80
+ if (message && /exceeded quota|forbidden: failed quota/i.test(message)) return "quota";
81
+ if (!message || /\bis forbidden: User\b|\bcannot \w+ resource\b/i.test(message)) return "rbac";
82
+ return undefined;
83
+ }
84
+
85
+ /** The API `Status` object, however the client-node deserializer left it: a
86
+ * parsed object for a status code it recognizes, the raw JSON text for one it
87
+ * does not (which is the 403 case, reported as "Unknown API Status Code!"). */
88
+ function status(err: unknown): Record<string, unknown> | undefined {
89
+ const body = (err as { body?: unknown })?.body;
90
+ const parsed = typeof body === "string" ? parseJson(body) : body;
91
+ return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
92
+ }
93
+
94
+ /** `Forbidden`, `NotFound`, `Invalid`, … — the one word safe to pass on. */
95
+ function statusReason(err: unknown): string | undefined {
96
+ const reason = status(err)?.reason;
97
+ return typeof reason === "string" && reason.trim() !== "" ? reason : undefined;
98
+ }
99
+
100
+ /** Read for classification only — it names cluster identities and never leaves. */
101
+ function statusMessage(err: unknown): string | undefined {
102
+ const message = status(err)?.message;
103
+ return typeof message === "string" ? message : undefined;
104
+ }
105
+
106
+ function parseJson(text: string): unknown {
107
+ try {
108
+ return JSON.parse(text);
109
+ } catch {
110
+ return undefined;
111
+ }
112
+ }
113
+
114
+ /**
115
+ * The HTTP status an error carries, or `undefined` when it carries none.
116
+ *
117
+ * The number check is the whole point: `code` is also where Node puts a system
118
+ * error's string code and where a `DOMException` puts its legacy numeric one, so
119
+ * an unreachable apiserver and an aborted request both arrive here looking like
120
+ * a status. Only a value in the HTTP range is one.
121
+ */
122
+ export function statusCode(err: unknown): number | undefined {
123
+ const e = err as { statusCode?: unknown; code?: unknown; response?: { statusCode?: unknown } };
124
+ for (const candidate of [e?.statusCode, e?.code, e?.response?.statusCode]) {
125
+ if (typeof candidate === "number" && candidate >= 100 && candidate <= 599) return candidate;
126
+ }
127
+ return undefined;
128
+ }
@@ -15,8 +15,8 @@ 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
20
  import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
21
21
  import { buildAppPod, buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
22
22
  import {
@@ -80,9 +80,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
80
80
  async function start(spec: BackendStartSpec): Promise<BackendSession> {
81
81
  // A watch session is a different pod shape and a different lifetime — it
82
82
  // 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.
83
+ // branches through this one.
86
84
  if (spec.mode === "watch") {
87
85
  return startWatchSession({ kube, config }, spec);
88
86
  }
@@ -104,9 +102,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
104
102
 
105
103
  let pod: V1Pod;
106
104
  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.
105
+ // Operator-predefined app (catalog image): self-contained, with no bundle
106
+ // to deliver — the pod runs the image's own entrypoint with the env the
107
+ // core route already merged.
110
108
  pod = buildAppPod({
111
109
  config,
112
110
  sessionId: spec.sessionId,
@@ -118,30 +116,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
118
116
  pullPolicy: spec.config.pullPolicy,
119
117
  });
120
118
  } 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.
119
+ // Deliver the body to the Pod's /app at boot via a tokenized, single-use
120
+ // URL, and run it on the plain kernel image: the kernel resolves its own
121
+ // module closure into the pod's cache emptyDir on the way up.
145
122
  const bundleUrl = await bundleStore.stageSessionBundle(spec.sessionId, spec.bundle);
146
123
 
147
124
  pod = buildSessionPod({
@@ -152,7 +129,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
152
129
  env: spec.env,
153
130
  ports: app.ports,
154
131
  limits,
155
- image,
132
+ image: spec.config.image || config.defaultImage,
133
+ pullPolicy: spec.config.pullPolicy,
156
134
  bundleUrl,
157
135
  inspect: spec.inspect,
158
136
  });
@@ -163,7 +141,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
163
141
  const created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
164
142
  podUid = created.metadata?.uid ?? "";
165
143
  } catch (err) {
166
- throw new SessionStartError("start_failed", "create", `failed to create pod: ${msg(err)}`, msg(err));
144
+ throw apiFailure(err, "create", "could not create the session pod");
167
145
  }
168
146
 
169
147
  let finished = false;
@@ -214,8 +192,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
214
192
  // Flip the session to `running` when the pod reaches Running. Deterministic
215
193
  // and independent of the session image's telo version — a readiness signal
216
194
  // 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`.
195
+ // flip). The provision progress (streamed before this) covers the slow part;
196
+ // module resolution and validation run while already `running`.
219
197
  const flipRunning = (): void => {
220
198
  if (readyFlipped || finished) return;
221
199
  readyFlipped = true;
@@ -301,7 +279,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
301
279
  } catch (err) {
302
280
  if (!runningSeen) {
303
281
  clearStartDeadline();
304
- rejectRunning(new Error(`failed to watch pod: ${msg(err)}`));
282
+ rejectRunning(new Error(`could not watch the session pod: ${apiReason(err)}`, { cause: err }));
305
283
  } else if (!finished) {
306
284
  setTimeout(() => void armWatch(), WATCH_REARM_DELAY_MS).unref?.();
307
285
  }
@@ -324,7 +302,16 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
324
302
  abortWatch();
325
303
  bundleStore.drop(spec.sessionId);
326
304
  await deletePod(kube, ns, podName);
327
- throw new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`, msg(err));
305
+ // `err` is one of OUR errors a pod-status failure message, a
306
+ // disappeared pod, the start deadline, a watch that could not be armed —
307
+ // so its message is already client-safe and stands. What must not be lost
308
+ // is its `cause`: the watch path attaches the raw ApiException there, and
309
+ // rewrapping without forwarding it drops the very detail the summarized
310
+ // message exists to relocate into the log.
311
+ throw withCause(
312
+ new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`),
313
+ (err as { cause?: unknown })?.cause ?? err,
314
+ );
328
315
  }
329
316
 
330
317
  // Attach a PTY to the running container: stdout → onOutput, stdin ← writes.
@@ -333,12 +320,20 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
333
320
  socket = ws as unknown as ResizableSocket;
334
321
  } catch (err) {
335
322
  // 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");
323
+ spec.onOutput(
324
+ appName,
325
+ Buffer.from(`\r\n[runner] failed to attach PTY: ${apiReason(err)}\r\n`),
326
+ "tty",
327
+ );
337
328
  }
338
329
 
339
330
  if (config.sessionIngressBaseDomain && app.ports.length > 0) {
340
331
  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");
332
+ spec.onOutput(
333
+ appName,
334
+ Buffer.from(`\r\n[runner] failed to create ingress: ${apiReason(err)}\r\n`),
335
+ "tty",
336
+ );
342
337
  });
343
338
  }
344
339
 
package/src/k8s/client.ts CHANGED
@@ -1,16 +1,8 @@
1
- import {
2
- Attach,
3
- BatchV1Api,
4
- CoreV1Api,
5
- KubeConfig,
6
- NetworkingV1Api,
7
- Watch,
8
- } from "@kubernetes/client-node";
1
+ import { Attach, CoreV1Api, KubeConfig, NetworkingV1Api, Watch } from "@kubernetes/client-node";
9
2
 
10
3
  export interface KubeClient {
11
4
  kc: KubeConfig;
12
5
  core: CoreV1Api;
13
- batch: BatchV1Api;
14
6
  networking: NetworkingV1Api;
15
7
  attach: Attach;
16
8
  watch: Watch;
@@ -31,7 +23,6 @@ export function createKubeClient(): KubeClient {
31
23
  return {
32
24
  kc,
33
25
  core: kc.makeApiClient(CoreV1Api),
34
- batch: kc.makeApiClient(BatchV1Api),
35
26
  networking: kc.makeApiClient(NetworkingV1Api),
36
27
  attach: new Attach(kc),
37
28
  watch: new Watch(kc),
@@ -1,20 +1,75 @@
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({
14
69
  config,
15
70
  sessionId: "abc123",
16
71
  podName: "telo-run-abc123",
17
- env: { SERVICE_TOKEN: "tok-operator", TELO_REGISTRY_URL: "https://registry.telo.run" },
72
+ env: { SERVICE_TOKEN: "tok-operator" },
18
73
  ports: [{ port: 8080, protocol: "tcp" }],
19
74
  limits: config.appLimits,
20
75
  image: "acme/tool:1",
@@ -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,
@@ -470,9 +459,6 @@ function workspaceContainer(args: BuildWatchPodArgs): V1Container {
470
459
  { name: "TELO_CACHE_DIR", value: `${CACHE_ROOT}/workspace` },
471
460
  { name: "HOME", value: HOME_DIR },
472
461
  { name: "npm_config_cache", value: `${HOME_DIR}/.npm` },
473
- ...(args.config.build.teloRegistryUrl
474
- ? [{ name: "TELO_REGISTRY_URL", value: args.config.build.teloRegistryUrl }]
475
- : []),
476
462
  ],
477
463
  ports: [{ containerPort: WORKSPACE_PORT, protocol: "TCP" }],
478
464
  resources: watchResources(args.limits),
@@ -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,7 @@ 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
133
  }
134
134
 
135
135
  export function msg(err: unknown): string {
@@ -6,7 +6,6 @@ import { buildSessionPod, buildWatchPod } from "./pod-spec.js";
6
6
 
7
7
  const BASE_ENV = {
8
8
  RUNNER_SELF_URL: "http://k8s-runner.telo-runner.svc:8062",
9
- RUNNER_IMAGE_REPOSITORY: "registry.telo-runner.svc:5000/telo-sessions",
10
9
  RUNNER_WATCH_SESSIONS: "true",
11
10
  };
12
11
 
@@ -23,6 +23,7 @@ import {
23
23
 
24
24
  import type { K8sRunnerConfig } from "../config.js";
25
25
  import { clampLimits } from "../limits.js";
26
+ import { apiFailure, apiReason } from "./api-error.js";
26
27
  import type { KubeClient } from "./client.js";
27
28
  import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
28
29
  import { buildWatchPod, inspectPortFor, WORKSPACE_PORT } from "./pod-spec.js";
@@ -54,11 +55,10 @@ export interface WatchSessionDeps {
54
55
  * A watch session: one pod, one workspace volume, one container per running
55
56
  * application, and the session outliving every run inside it.
56
57
  *
57
- * What it does NOT do is as load-bearing as what it does: it never builds an
58
- * image. The build path exists to put a dependency closure on disk before boot;
59
- * a watch session resolves its own into a per-app cache directory that lives as
60
- * long as the pod, so the download happens once per app per session and every
61
- * later reload resolves from local disk.
58
+ * Its cache is what separates it from a run session: the module closure lands in
59
+ * a directory that lives as long as the POD rather than as long as one run, so
60
+ * the download happens once per session and every later reload resolves from
61
+ * local disk.
62
62
  */
63
63
  export async function startWatchSession(
64
64
  deps: WatchSessionDeps,
@@ -117,12 +117,7 @@ export async function startWatchSession(
117
117
  try {
118
118
  created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
119
119
  } catch (err) {
120
- throw new SessionStartError(
121
- "start_failed",
122
- "create",
123
- `failed to create pod: ${msg(err)}`,
124
- msg(err),
125
- );
120
+ throw apiFailure(err, "create", "could not create the session pod");
126
121
  }
127
122
 
128
123
  const abort = new AbortController();
@@ -299,7 +294,7 @@ export async function startWatchSession(
299
294
  // channel rather than aborting the start.
300
295
  spec.onOutput(
301
296
  app.name,
302
- Buffer.from(`\r\n[runner] failed to attach: ${msg(err)}\r\n`),
297
+ Buffer.from(`\r\n[runner] failed to attach: ${apiReason(err)}\r\n`),
303
298
  tagFor(app, "stderr"),
304
299
  );
305
300
  }
@@ -364,7 +359,7 @@ export async function startWatchSession(
364
359
  await publishEndpoints(rt, apps);
365
360
  } catch (err) {
366
361
  spec.onEndpoints(appName, {
367
- rejected: accepted.map((p) => ({ port: p.port, reason: msg(err) })),
362
+ rejected: accepted.map((p) => ({ port: p.port, reason: apiReason(err) })),
368
363
  });
369
364
  return;
370
365
  }
package/src/server.ts CHANGED
@@ -53,9 +53,8 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
53
53
  // editor attach an agent to every run and every run fail.
54
54
  agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
55
55
  }),
56
- defaultRegistryUrl: process.env.TELO_REGISTRY_URL,
57
56
  // Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
58
- // /v1/capabilities; app sessions run the catalog image directly (no build).
57
+ // /v1/capabilities; app sessions run the catalog image directly.
59
58
  apps,
60
59
  validateConfig: catalog
61
60
  ? (sessionConfig: SessionConfig): string | undefined =>
@@ -65,8 +64,8 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
65
64
  `Allowed images: ${catalog.current().join(", ")}`
66
65
  : undefined,
67
66
  });
68
- // Mount the internal, tokenized fetch route on the same app so a build Job's
69
- // initContainer can pull the build-context tarball (bundle + Dockerfile).
67
+ // Mount the internal, tokenized fetch route on the same app so a session pod's
68
+ // initContainer can pull the bundle tarball.
70
69
  deps.bundleStore.registerRoute(handle.app);
71
70
  return handle;
72
71
  }