@telorun/k8s-runner 0.10.2 → 0.12.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.
@@ -0,0 +1,24 @@
1
+ import type { K8sRunnerConfig } from "../config.js";
2
+ import type { KubeClient } from "./client.js";
3
+ export declare function workspaceConfigMapName(): string;
4
+ /** Create the ConfigMap if it is not already there. Content-addressed, so an
5
+ * existing one with this name already holds exactly these bytes and a 409 is
6
+ * success rather than a conflict to resolve. */
7
+ export declare function ensureWorkspaceConfigMap(kube: KubeClient, config: K8sRunnerConfig): Promise<string>;
8
+ /**
9
+ * Delete workspace-app ConfigMaps no live pod mounts.
10
+ *
11
+ * The name is content-addressed, so every release that changes the manifest
12
+ * leaves the previous one behind — permanently, since nothing else removes it.
13
+ * Run at boot, alongside the orphan pod reap: a session that survives a runner
14
+ * restart is impossible (the registry is in memory), so anything a live pod
15
+ * still references is a pod this process is about to reap anyway.
16
+ *
17
+ * Keeps the CURRENT hash unconditionally, and skips any map a pod still lists —
18
+ * a mounted ConfigMap that disappears makes the kubelet fail the pod.
19
+ */
20
+ export declare function sweepWorkspaceConfigMaps(kube: KubeClient, config: K8sRunnerConfig, log: {
21
+ info(obj: unknown, msg: string): void;
22
+ warn(obj: unknown, msg: string): void;
23
+ }): Promise<void>;
24
+ //# sourceMappingURL=workspace-configmap.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-configmap.d.ts","sourceRoot":"","sources":["../../src/k8s/workspace-configmap.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAmB9C,wBAAgB,sBAAsB,IAAI,MAAM,CAG/C;AAED;;iDAEiD;AACjD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,MAAM,CAAC,CA6BjB;AAeD;;;;;;;;;;;GAWG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,eAAe,EACvB,GAAG,EAAE;IAAE,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACpF,OAAO,CAAC,IAAI,CAAC,CAqCf"}
@@ -0,0 +1,124 @@
1
+ import { createHash } from "node:crypto";
2
+ import { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "@telorun/runner-core";
3
+ /**
4
+ * The workspace application's manifest, delivered to the pod as a ConfigMap.
5
+ *
6
+ * Named by a hash of its own content, so a runner upgrade that changes the
7
+ * manifest creates a NEW ConfigMap and leaves running sessions mounting the one
8
+ * they booted with — a mutable name would swap a manifest under a live pod, and
9
+ * kubelet ConfigMap propagation is asynchronous, so the failure would be a
10
+ * workspace container that restarts into a different surface at an arbitrary
11
+ * moment.
12
+ *
13
+ * Reconciled by the runner rather than shipped by the chart, so the manifest and
14
+ * the code that depends on it version as one artifact: a chart-only upgrade
15
+ * cannot leave them skewed. The cost is one RBAC rule (`configmaps: get, create`
16
+ * in the session namespace).
17
+ */
18
+ const WORKSPACE_CONFIGMAP_PREFIX = "telo-workspace-app-";
19
+ export function workspaceConfigMapName() {
20
+ const digest = createHash("sha256").update(workspaceAppManifest()).digest("hex").slice(0, 12);
21
+ return `${WORKSPACE_CONFIGMAP_PREFIX}${digest}`;
22
+ }
23
+ /** Create the ConfigMap if it is not already there. Content-addressed, so an
24
+ * existing one with this name already holds exactly these bytes and a 409 is
25
+ * success rather than a conflict to resolve. */
26
+ export async function ensureWorkspaceConfigMap(kube, config) {
27
+ const name = workspaceConfigMapName();
28
+ try {
29
+ await kube.core.readNamespacedConfigMap({ name, namespace: config.sessionNamespace });
30
+ return name;
31
+ }
32
+ catch (err) {
33
+ if (!isNotFound(err))
34
+ throw err;
35
+ }
36
+ try {
37
+ await kube.core.createNamespacedConfigMap({
38
+ namespace: config.sessionNamespace,
39
+ body: {
40
+ apiVersion: "v1",
41
+ kind: "ConfigMap",
42
+ metadata: {
43
+ name,
44
+ namespace: config.sessionNamespace,
45
+ labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
46
+ },
47
+ data: { [WORKSPACE_APP_FILENAME]: workspaceAppManifest() },
48
+ },
49
+ });
50
+ }
51
+ catch (err) {
52
+ // Two runners (or two concurrent starts) racing to create the same
53
+ // content-addressed name is not a failure — the loser mounts the winner's
54
+ // identical bytes.
55
+ if (!isConflict(err))
56
+ throw err;
57
+ }
58
+ return name;
59
+ }
60
+ function statusOf(err) {
61
+ const e = err;
62
+ return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
63
+ }
64
+ function isNotFound(err) {
65
+ return statusOf(err) === 404;
66
+ }
67
+ function isConflict(err) {
68
+ return statusOf(err) === 409;
69
+ }
70
+ /**
71
+ * Delete workspace-app ConfigMaps no live pod mounts.
72
+ *
73
+ * The name is content-addressed, so every release that changes the manifest
74
+ * leaves the previous one behind — permanently, since nothing else removes it.
75
+ * Run at boot, alongside the orphan pod reap: a session that survives a runner
76
+ * restart is impossible (the registry is in memory), so anything a live pod
77
+ * still references is a pod this process is about to reap anyway.
78
+ *
79
+ * Keeps the CURRENT hash unconditionally, and skips any map a pod still lists —
80
+ * a mounted ConfigMap that disappears makes the kubelet fail the pod.
81
+ */
82
+ export async function sweepWorkspaceConfigMaps(kube, config, log) {
83
+ const keep = new Set([workspaceConfigMapName()]);
84
+ let pods;
85
+ let maps;
86
+ try {
87
+ [pods, maps] = await Promise.all([
88
+ kube.core.listNamespacedPod({ namespace: config.sessionNamespace }),
89
+ kube.core.listNamespacedConfigMap({
90
+ namespace: config.sessionNamespace,
91
+ labelSelector: `app.kubernetes.io/managed-by=${config.managedByLabel}`,
92
+ }),
93
+ ]);
94
+ }
95
+ catch (err) {
96
+ // Housekeeping, not correctness — a runner that cannot list is a runner
97
+ // whose next boot will try again.
98
+ log.warn({ err }, "could not sweep workspace-app ConfigMaps");
99
+ return;
100
+ }
101
+ for (const pod of pods.items ?? []) {
102
+ for (const volume of pod.spec?.volumes ?? []) {
103
+ if (volume.configMap?.name)
104
+ keep.add(volume.configMap.name);
105
+ }
106
+ }
107
+ let removed = 0;
108
+ for (const map of maps.items ?? []) {
109
+ const name = map.metadata?.name;
110
+ if (!name?.startsWith(WORKSPACE_CONFIGMAP_PREFIX) || keep.has(name))
111
+ continue;
112
+ try {
113
+ await kube.core.deleteNamespacedConfigMap({ name, namespace: config.sessionNamespace });
114
+ removed += 1;
115
+ }
116
+ catch (err) {
117
+ if (!isNotFound(err))
118
+ log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
119
+ }
120
+ }
121
+ if (removed > 0)
122
+ log.info({ removed }, "swept stale workspace-app ConfigMaps");
123
+ }
124
+ //# sourceMappingURL=workspace-configmap.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"workspace-configmap.js","sourceRoot":"","sources":["../../src/k8s/workspace-configmap.ts"],"names":[],"mappings":"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"}
@@ -1 +1 @@
1
- {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAKhB,KAAK,aAAa,EAClB,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAM3F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB;qFACiF;IACjF,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAkCzE"}
1
+ {"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAMhB,KAAK,aAAa,EAClB,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAO3F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB;qFACiF;IACjF,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAwCzE"}
package/dist/server.js CHANGED
@@ -1,16 +1,18 @@
1
- import { BaseImageCatalog, buildServer as coreBuildServer, loadResolvedApps, loadTermsFromEnv, stopAllSessions, } from "@telorun/runner-core";
1
+ import { BaseImageCatalog, buildServer as coreBuildServer, coResidentAgentNames, loadResolvedApps, loadTermsFromEnv, stopAllSessions, } from "@telorun/runner-core";
2
2
  import packageJson from "../package.json" with { type: "json" };
3
3
  import { BundleStore } from "./bundle-store.js";
4
4
  import { kubernetesRunnerCapabilities } from "./capabilities.js";
5
5
  import { loadK8sRunnerConfig, RunnerConfigError } from "./config.js";
6
6
  import { createKubernetesBackend } from "./k8s/backend.js";
7
7
  import { createKubeClient } from "./k8s/client.js";
8
+ import { sweepWorkspaceConfigMaps } from "./k8s/workspace-configmap.js";
8
9
  const VERSION = packageJson.version;
9
10
  export async function buildServer(deps) {
10
11
  const { catalog } = deps;
11
12
  // Load terms once; the capabilities getter is re-resolved per request so a
12
13
  // refreshed catalog (new tags) shows up without restarting the runner.
13
14
  const terms = loadTermsFromEnv(process.env);
15
+ const apps = loadResolvedApps(process.env);
14
16
  const handle = await coreBuildServer({
15
17
  backend: deps.backend,
16
18
  config: deps.config,
@@ -21,11 +23,16 @@ export async function buildServer(deps) {
21
23
  defaultImage: deps.config.defaultImage,
22
24
  terms,
23
25
  imageEnum: catalog?.current(),
26
+ watch: deps.config.watch.enabled,
27
+ // Only entries that declare a port: the session route refuses an agent
28
+ // without one, and advertising what will be rejected is what makes the
29
+ // editor attach an agent to every run and every run fail.
30
+ agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
24
31
  }),
25
32
  defaultRegistryUrl: process.env.TELO_REGISTRY_URL,
26
33
  // Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
27
34
  // /v1/capabilities; app sessions run the catalog image directly (no build).
28
- apps: loadResolvedApps(process.env),
35
+ apps,
29
36
  validateConfig: catalog
30
37
  ? (sessionConfig) => catalog.isAllowed(sessionConfig.image)
31
38
  ? undefined
@@ -75,6 +82,9 @@ async function main() {
75
82
  // Reap pods orphaned by a prior runner process (in-memory registry).
76
83
  if (backend.reapOrphans) {
77
84
  await backend.reapOrphans().catch((err) => app.log.warn({ err }, "orphan reap failed"));
85
+ // After the pods are gone: a content-addressed workspace ConfigMap outlives
86
+ // every release that changed the manifest, and nothing else removes it.
87
+ await sweepWorkspaceConfigMaps(kube, config, app.log);
78
88
  }
79
89
  try {
80
90
  await app.listen({ port: config.port, host: "0.0.0.0" });
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@telorun/k8s-runner",
3
- "version": "0.10.2",
3
+ "version": "0.12.0",
4
4
  "description": "Telo Kubernetes Runner - HTTP service that runs Telo Applications as sandboxed Kubernetes Pods. A backend over @telorun/runner-core, sibling to docker-runner.",
5
5
  "keywords": [
6
6
  "telo",
@@ -23,7 +23,7 @@
23
23
  "dependencies": {
24
24
  "@kubernetes/client-node": "^1.4.0",
25
25
  "fastify": "^5.12.1",
26
- "@telorun/runner-core": "0.8.2"
26
+ "@telorun/runner-core": "0.10.0"
27
27
  },
28
28
  "devDependencies": {
29
29
  "@types/node": "^20.0.0",
@@ -12,6 +12,11 @@ export interface KubernetesRunnerCapabilitiesOptions {
12
12
  defaultImage: string;
13
13
  terms?: RunnerTerms;
14
14
  imageEnum?: string[];
15
+ /** Whether the operator enabled watch sessions. Advertised so a client knows
16
+ * before it asks — a runner with watch off rejects the field. */
17
+ watch: boolean;
18
+ /** Catalog names admissible as a session's co-resident `agent`. */
19
+ agents?: string[];
15
20
  }
16
21
 
17
22
  /** What k8s-runner advertises on `/v1/capabilities`. The runner serves
@@ -33,7 +38,15 @@ export function kubernetesRunnerCapabilities(
33
38
  return {
34
39
  displayName,
35
40
  description,
36
- features: { io: true, ports: true },
41
+ features: {
42
+ // Both attach modes: the kubernetes attach subresource without a TTY
43
+ // already gives separate stdout and stderr channels, so `streams` invents
44
+ // nothing at the transport layer.
45
+ io: ["tty", "streams"],
46
+ ports: true,
47
+ watch: opts.watch,
48
+ ...(opts.agents && opts.agents.length > 0 ? { agents: opts.agents } : {}),
49
+ },
37
50
  config: {
38
51
  schema: sessionConfigSchema({
39
52
  imageDefault: defaultImage,
@@ -1,10 +1,11 @@
1
1
  import { PassThrough, Writable } from "node:stream";
2
2
 
3
- import type { V1ContainerState, V1ContainerStatus, V1Pod } from "@kubernetes/client-node";
3
+ import type { V1Pod } from "@kubernetes/client-node";
4
4
  import type {
5
5
  AvailabilityReport,
6
6
  BackendSession,
7
7
  BackendStartSpec,
8
+ PortMapping,
8
9
  ProbeConfig,
9
10
  RunStatus,
10
11
  RunnerBackend,
@@ -18,6 +19,19 @@ import type { KubeClient } from "./client.js";
18
19
  import { ensureSessionImage } from "./image-build.js";
19
20
  import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
20
21
  import { buildAppPod, buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
22
+ import {
23
+ deletePod,
24
+ is404,
25
+ msg,
26
+ podFailureMessage,
27
+ podStatus,
28
+ podPhase,
29
+ provisionMessage,
30
+ terminalStatus,
31
+ } from "./pod-status.js";
32
+ import { startWatchSession } from "./watch-session.js";
33
+
34
+ export { podFailureMessage } from "./pod-status.js";
21
35
 
22
36
  /** Minimal surface of the websocket client-node's Attach returns. */
23
37
  interface ResizableSocket {
@@ -42,7 +56,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
42
56
  const { kube, config, bundleStore } = deps;
43
57
  const ns = config.sessionNamespace;
44
58
 
45
- async function probe(_probe: ProbeConfig): Promise<AvailabilityReport> {
59
+ async function probe(probeConfig: ProbeConfig): Promise<AvailabilityReport> {
46
60
  try {
47
61
  await kube.core.readNamespace({ name: ns });
48
62
  } catch {
@@ -64,7 +78,23 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
64
78
  }
65
79
 
66
80
  async function start(spec: BackendStartSpec): Promise<BackendSession> {
81
+ // A watch session is a different pod shape and a different lifetime — it
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.
86
+ if (spec.mode === "watch") {
87
+ return startWatchSession({ kube, config }, spec);
88
+ }
89
+ return startRunSession(spec);
90
+ }
91
+
92
+ async function startRunSession(spec: BackendStartSpec): Promise<BackendSession> {
67
93
  const podName = `telo-run-${spec.sessionId}`;
94
+ // A run session is one application by construction — `apps` carries exactly
95
+ // one entry, defaulted by core when the request declared none.
96
+ const app = spec.apps[0]!;
97
+ const appName = app.name;
68
98
  // The /v1 contract carries no per-request limits yet, so `requested` is
69
99
  // undefined and the configured ceiling is always the effective limit. When
70
100
  // a control plane begins passing limits, plumb them here — the clamp is
@@ -82,7 +112,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
82
112
  sessionId: spec.sessionId,
83
113
  podName,
84
114
  env: spec.env,
85
- ports: spec.ports,
115
+ ports: app.ports,
86
116
  limits,
87
117
  image: spec.config.image,
88
118
  pullPolicy: spec.config.pullPolicy,
@@ -103,10 +133,10 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
103
133
  },
104
134
  {
105
135
  bundle: spec.bundle,
106
- entryRelativePath: spec.entryRelativePath,
136
+ entryRelativePath: app.entryRelativePath,
107
137
  baseImage: spec.config.image || config.defaultImage,
108
138
  pullPolicy: spec.config.pullPolicy,
109
- onProgress: (message, done) => spec.onProgress("build", message, done),
139
+ onProgress: (message, done) => spec.onProgress("build", message, done, appName),
110
140
  },
111
141
  );
112
142
 
@@ -118,9 +148,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
118
148
  config,
119
149
  sessionId: spec.sessionId,
120
150
  podName,
121
- entryRelativePath: spec.entryRelativePath,
151
+ entryRelativePath: app.entryRelativePath,
122
152
  env: spec.env,
123
- ports: spec.ports,
153
+ ports: app.ports,
124
154
  limits,
125
155
  image,
126
156
  bundleUrl,
@@ -151,8 +181,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
151
181
 
152
182
  const stdin = new PassThrough();
153
183
  const stdout = new Writable({
154
- write(chunk: Buffer, _enc, cb) {
155
- if (chunk?.byteLength) spec.onOutput(Buffer.from(chunk));
184
+ write(chunk: Buffer, encoding, cb) {
185
+ if (chunk?.byteLength) spec.onOutput(appName, Buffer.from(chunk), "tty");
156
186
  cb();
157
187
  },
158
188
  });
@@ -189,7 +219,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
189
219
  const flipRunning = (): void => {
190
220
  if (readyFlipped || finished) return;
191
221
  readyFlipped = true;
192
- spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, spec.ports) });
222
+ spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, app.ports) });
193
223
  };
194
224
 
195
225
  let resolveRunning!: () => void;
@@ -207,7 +237,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
207
237
  const provision = provisionMessage(obj);
208
238
  if (provision && provision !== lastProvision) {
209
239
  lastProvision = provision;
210
- spec.onProgress("provision", provision);
240
+ spec.onProgress("provision", provision, undefined, appName);
211
241
  }
212
242
  }
213
243
  if (phase === "Running" && !runningSeen) {
@@ -253,7 +283,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
253
283
  const req = await kube.watch.watch(
254
284
  `/api/v1/namespaces/${ns}/pods`,
255
285
  { fieldSelector: `metadata.name=${podName}` },
256
- (_type: string, obj: unknown) => handlePhase(obj),
286
+ (type: string, obj: unknown) => handlePhase(obj),
257
287
  () => {
258
288
  if (finished) return;
259
289
  void reconcileOnce().then(() => {
@@ -303,12 +333,12 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
303
333
  socket = ws as unknown as ResizableSocket;
304
334
  } catch (err) {
305
335
  // Attach failure isn't fatal — status still flows; surface the degraded PTY.
306
- spec.onOutput(Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`));
336
+ spec.onOutput(appName, Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`), "tty");
307
337
  }
308
338
 
309
- if (config.sessionIngressBaseDomain && spec.ports.length > 0) {
310
- await createIngress(deps, spec.sessionId, podName, podUid, spec.ports).catch((err) => {
311
- spec.onOutput(Buffer.from(`\r\n[runner] failed to create ingress: ${msg(err)}\r\n`));
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");
312
342
  });
313
343
  }
314
344
 
@@ -331,11 +361,11 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
331
361
  if (ip) {
332
362
  void relayDebugStream({
333
363
  url: `http://${ip}:${INSPECT_PORT}/events`,
334
- onFrame: spec.onDebug,
364
+ onFrame: (frame) => spec.onDebug(appName, frame),
335
365
  signal: debugAbort.signal,
336
366
  });
337
367
  } else {
338
- spec.onOutput(Buffer.from("\r\n[runner] debug stream unavailable: pod IP unknown\r\n"));
368
+ spec.onOutput(appName, Buffer.from("\r\n[runner] debug stream unavailable: pod IP unknown\r\n"), "tty");
339
369
  }
340
370
  }
341
371
 
@@ -343,7 +373,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
343
373
  // wrong port) is unreachable on the pod network and surfaces only as a
344
374
  // downstream 502. Watch each advertised tcp port from the runner and report
345
375
  // per-port state to studio's endpoint badge. Background; finish() aborts it.
346
- const tcpPorts = spec.ports.filter((p) => p.protocol === "tcp").map((p) => p.port);
376
+ const tcpPorts = app.ports.filter((p) => p.protocol === "tcp").map((p) => p.port);
347
377
  if (tcpPorts.length > 0) {
348
378
  void (async () => {
349
379
  const ip = await resolvePodIP();
@@ -351,13 +381,13 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
351
381
  if (!ip) {
352
382
  // Couldn't resolve the pod IP to probe — report unverified rather than
353
383
  // leaving the badge spinning forever.
354
- for (const port of tcpPorts) spec.onReachability(port, "unreachable");
384
+ for (const port of tcpPorts) spec.onReachability(appName, port, "unreachable");
355
385
  return;
356
386
  }
357
387
  await watchReachability({
358
388
  host: ip,
359
389
  ports: tcpPorts,
360
- onState: (port, state) => spec.onReachability(port, state),
390
+ onState: (port, state) => spec.onReachability(appName, port, state),
361
391
  signal: reachAbort.signal,
362
392
  });
363
393
  })();
@@ -366,14 +396,14 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
366
396
  // `flipRunning` already announced `running` from the watch's Running
367
397
  // transition (which `await running` above waited on).
368
398
  return {
369
- writeStdin(bytes) {
399
+ writeStdin(app, bytes) {
370
400
  try {
371
401
  stdin.write(Buffer.from(bytes));
372
402
  } catch {
373
403
  /* stream ended */
374
404
  }
375
405
  },
376
- resize(cols, rows) {
406
+ resize(app, cols, rows) {
377
407
  if (!socket) return;
378
408
  try {
379
409
  const payload = Buffer.from(JSON.stringify({ Width: cols, Height: rows }));
@@ -423,7 +453,7 @@ async function createIngress(
423
453
  sessionId: string,
424
454
  podName: string,
425
455
  podUid: string,
426
- ports: BackendStartSpec["ports"],
456
+ ports: PortMapping[],
427
457
  ): Promise<void> {
428
458
  const { kube, config } = deps;
429
459
  const ns = config.sessionNamespace;
@@ -442,15 +472,6 @@ async function createIngress(
442
472
  await kube.networking.createNamespacedIngress({ namespace: ns, body: ingress });
443
473
  }
444
474
 
445
- async function deletePod(kube: KubeClient, ns: string, name: string): Promise<void> {
446
- try {
447
- await kube.core.deleteNamespacedPod({ name, namespace: ns, gracePeriodSeconds: 0 });
448
- } catch (err) {
449
- // 404 = already gone (natural exit + GC). Anything else is a real failure.
450
- if (!is404(err)) throw err;
451
- }
452
- }
453
-
454
475
  async function clusterReachable(kube: KubeClient): Promise<boolean> {
455
476
  try {
456
477
  await kube.core.listNamespace();
@@ -459,126 +480,3 @@ async function clusterReachable(kube: KubeClient): Promise<boolean> {
459
480
  return false;
460
481
  }
461
482
  }
462
-
463
- function podStatus(obj: unknown): V1Pod["status"] | undefined {
464
- return (obj as V1Pod | undefined)?.status;
465
- }
466
-
467
- function podPhase(obj: unknown): string | undefined {
468
- return podStatus(obj)?.phase;
469
- }
470
-
471
- /** A coming-up message for the studio feed while the Pod is still scheduling /
472
- * pulling / delivering the body / creating the container; undefined once running. */
473
- function provisionMessage(obj: unknown): string | undefined {
474
- const status = podStatus(obj);
475
- if (status?.phase !== "Pending") return undefined;
476
- const containers = [
477
- ...(status.initContainerStatuses ?? []),
478
- ...(status.containerStatuses ?? []),
479
- ];
480
- for (const cs of containers) {
481
- const reason = cs.state?.waiting?.reason;
482
- if (reason) return humanizeWaitReason(reason);
483
- }
484
- return "Scheduling";
485
- }
486
-
487
- function humanizeWaitReason(reason: string): string {
488
- switch (reason) {
489
- case "ContainerCreating":
490
- return "Creating container";
491
- case "PodInitializing":
492
- return "Delivering application";
493
- case "ErrImagePull":
494
- case "ImagePullBackOff":
495
- return "Pulling image";
496
- default:
497
- return reason;
498
- }
499
- }
500
-
501
- function terminalStatus(obj: unknown, userStopped: boolean): RunStatus {
502
- if (userStopped) return { kind: "stopped" };
503
- const phase = podPhase(obj);
504
- if (phase === "Succeeded") return { kind: "exited", code: containerExitCode(obj) ?? 0 };
505
- return { kind: "failed", message: podFailureMessage(obj) };
506
- }
507
-
508
- // Exit code of the main session container — used to report a clean exit.
509
- function containerExitCode(obj: unknown): number | null {
510
- const term = podStatus(obj)?.containerStatuses?.[0]?.state?.terminated;
511
- return typeof term?.exitCode === "number" ? term.exitCode : null;
512
- }
513
-
514
- const MAX_FAILURE_DETAIL = 500;
515
-
516
- /**
517
- * Builds an actionable failure message from a terminal Pod status. Init
518
- * containers are inspected first: a failed init container leaves the main
519
- * container unstarted, so reading only `containerStatuses` would fall through
520
- * to the bare "pod failed". For prebuilt session pods the common failure is the
521
- * main container itself (image pull, OOM, a non-zero exit).
522
- */
523
- export function podFailureMessage(obj: unknown): string {
524
- const status = podStatus(obj);
525
- const fromContainer = firstContainerProblem(status);
526
- if (fromContainer) return fromContainer;
527
- if (status?.message) return truncateDetail(status.message);
528
- if (status?.reason) return status.reason;
529
- return "pod failed";
530
- }
531
-
532
- function firstContainerProblem(status: V1Pod["status"] | undefined): string | undefined {
533
- const groups: Array<[string, V1ContainerStatus[] | undefined]> = [
534
- ["init container", status?.initContainerStatuses],
535
- ["container", status?.containerStatuses],
536
- ];
537
- for (const [label, statuses] of groups) {
538
- for (const cs of statuses ?? []) {
539
- const problem = containerStateProblem(cs.state) ?? containerStateProblem(cs.lastState);
540
- if (problem) return `${label} "${cs.name}" ${problem}`;
541
- }
542
- }
543
- return undefined;
544
- }
545
-
546
- function containerStateProblem(state: V1ContainerState | undefined): string | undefined {
547
- const term = state?.terminated;
548
- if (term && term.exitCode !== 0) {
549
- const reason = term.reason ? `${term.reason} ` : "";
550
- const detail = term.message ? `: ${truncateDetail(term.message)}` : "";
551
- return `failed: ${reason}(exit code ${term.exitCode ?? "unknown"})${detail}`;
552
- }
553
- const waiting = state?.waiting;
554
- if (waiting?.reason && isBlockingWaitReason(waiting.reason)) {
555
- const detail = waiting.message ? `: ${truncateDetail(waiting.message)}` : "";
556
- return `waiting: ${waiting.reason}${detail}`;
557
- }
558
- return undefined;
559
- }
560
-
561
- // Benign transient reasons the kubelet reports while a Pod is still coming up.
562
- function isBlockingWaitReason(reason: string): boolean {
563
- return reason !== "PodInitializing" && reason !== "ContainerCreating";
564
- }
565
-
566
- function truncateDetail(text: string): string {
567
- const trimmed = text.trim();
568
- return trimmed.length > MAX_FAILURE_DETAIL ? `${trimmed.slice(0, MAX_FAILURE_DETAIL)}…` : trimmed;
569
- }
570
-
571
- function is404(err: unknown): boolean {
572
- const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
573
- return e?.statusCode === 404 || e?.code === 404 || e?.response?.statusCode === 404;
574
- }
575
-
576
- function msg(err: unknown): string {
577
- if (err instanceof Error) return err.message;
578
- if (typeof err === "string") return err;
579
- try {
580
- return JSON.stringify(err);
581
- } catch {
582
- return String(err);
583
- }
584
- }