@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.
@@ -1,8 +1,13 @@
1
- import type { V1Pod } from "@kubernetes/client-node";
1
+ import type { V1Container, V1Pod, V1Volume, V1VolumeMount } from "@kubernetes/client-node";
2
2
 
3
3
  import type { K8sRunnerConfig } from "../config.js";
4
4
  import type { ResolvedLimits } from "../limits.js";
5
- import type { PortMapping, PullPolicy } from "@telorun/runner-core";
5
+ import type {
6
+ BackendAppSpec,
7
+ PortMapping,
8
+ PullPolicy,
9
+ ResolvedRunnerApp,
10
+ } from "@telorun/runner-core";
6
11
 
7
12
  export interface BuildPodArgs {
8
13
  config: K8sRunnerConfig;
@@ -273,3 +278,318 @@ function pullPolicyToK8s(policy: PullPolicy): string {
273
278
  return "IfNotPresent";
274
279
  }
275
280
  }
281
+
282
+ // ---------------------------------------------------------------------------
283
+ // Watch sessions — one pod, one workspace, one container per running application
284
+ // ---------------------------------------------------------------------------
285
+
286
+ /** Where every container sees the shared workspace volume. */
287
+ const WORKSPACE_DIR = "/workspace";
288
+ /**
289
+ * Cache root for the WORKSPACE container only.
290
+ *
291
+ * The application containers deliberately have none: their cache is anchored by
292
+ * the `telo-workspace.yaml` marker seeded at the workspace root, which puts one
293
+ * cache under `/workspace/.telo` for every app in the session — so two apps
294
+ * importing the same module resolve it once between them. `TELO_CACHE_DIR`
295
+ * outranks the marker, so setting it per app is exactly what would undo that.
296
+ *
297
+ * The workspace container needs an explicit root because its manifest lives
298
+ * OUTSIDE the workspace: the kernel walks up from the entry file, which for it
299
+ * is `/opt/telo-workspace`, so it would never see the marker.
300
+ */
301
+ const CACHE_ROOT = "/telo-cache";
302
+ /** Where the workspace application's own manifest is mounted, read-only. It is
303
+ * outside `/workspace` on purpose — the user's tree is theirs, and an
304
+ * infrastructure manifest sitting in it would be diffed, listed and editable. */
305
+ const WORKSPACE_APP_DIR = "/opt/telo-workspace";
306
+ /** Port the workspace container serves its HTTP surface on, inside the pod. */
307
+ export const WORKSPACE_PORT = 8099;
308
+
309
+ /** Each app's kernel debug endpoint. Containers in one pod share a network
310
+ * namespace, so the port has to differ per app or the second bind fails. */
311
+ export function inspectPortFor(index: number): number {
312
+ return INSPECT_PORT + index;
313
+ }
314
+
315
+ export interface BuildWatchPodArgs {
316
+ config: K8sRunnerConfig;
317
+ sessionId: string;
318
+ podName: string;
319
+ /** Session-declared env. Goes on every `app-<name>` container and NOWHERE
320
+ * else — `workspace` serves files and holds no secrets, and the agent gets
321
+ * the operator's env instead. */
322
+ env: Record<string, string>;
323
+ apps: BackendAppSpec[];
324
+ /** Resolved catalog entry for the co-resident agent, when one was requested.
325
+ * Its env is the operator's, LLM key included. */
326
+ agent?: ResolvedRunnerApp;
327
+ limits: ResolvedLimits;
328
+ /** 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. */
332
+ image: string;
333
+ pullPolicy: PullPolicy;
334
+ /** Name of the ConfigMap holding the workspace application's manifest. */
335
+ workspaceAppConfigMap: string;
336
+ }
337
+
338
+ /**
339
+ * The co-resident watch pod.
340
+ *
341
+ * One pod, one workspace volume, and one container per running application. An
342
+ * edit reaches the volume two ways and only two: the agent writes files directly
343
+ * with its own filesystem tools, and everyone outside the pod goes through the
344
+ * `workspace` container's HTTP surface. Both land on one volume that N watchers
345
+ * observe — which is what replaces the tokenized body tarball, the per-session
346
+ * bundle re-fetch and the separate agent session.
347
+ *
348
+ * Three properties are load-bearing and easy to lose:
349
+ *
350
+ * - **A shared GID.** Every container reads and writes `/workspace`, so the pod
351
+ * needs an `fsGroup` they all share. Without it the agent writes files the app
352
+ * cannot read, which surfaces as a manifest that "does not exist" one reload
353
+ * after it was written.
354
+ * - **The env split IS the credential boundary.** It used to be structural (two
355
+ * pods) and is now a code invariant (containers in one pod): the operator env
356
+ * goes on `agent` alone, every `app-<name>` gets the session's declared env
357
+ * and nothing else, and `workspace` gets neither.
358
+ * - **`CLICOLOR_FORCE` is set per app, and only under `io: "tty"`.** Forcing
359
+ * colour in a mode whose whole purpose is "show me what production sees"
360
+ * defeats the mode. It is set here rather than baked into the image because
361
+ * the usual way to override an image default (an empty value in a pod spec)
362
+ * sets the variable EMPTY, which the precedence order reads as present and
363
+ * forcing — so a baked default would be undisableable by the normal means.
364
+ */
365
+ export function buildWatchPod(args: BuildWatchPodArgs): V1Pod {
366
+ const { config, limits } = args;
367
+
368
+ const containers: V1Container[] = [
369
+ workspaceContainer(args),
370
+ ...args.apps.map((app, index) => appContainer(args, app, index)),
371
+ ];
372
+ if (args.agent) containers.push(agentContainer(args, args.agent));
373
+
374
+ return {
375
+ apiVersion: "v1",
376
+ kind: "Pod",
377
+ metadata: {
378
+ name: args.podName,
379
+ namespace: config.sessionNamespace,
380
+ labels: {
381
+ "app.kubernetes.io/managed-by": config.managedByLabel,
382
+ "telo.run/session-id": args.sessionId,
383
+ "telo.run/mode": "watch",
384
+ },
385
+ },
386
+ spec: {
387
+ restartPolicy: "Never",
388
+ // One deadline covers every container, so it takes the longer (agent)
389
+ // ceiling and lets idleness do the real work — an hour would kill a
390
+ // conversation mid-turn.
391
+ activeDeadlineSeconds: config.watch.maxTtlSeconds,
392
+ automountServiceAccountToken: false,
393
+ ...(config.build.imagePullSecret
394
+ ? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
395
+ : {}),
396
+ ...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
397
+ securityContext: {
398
+ runAsNonRoot: true,
399
+ runAsUser: 1000,
400
+ runAsGroup: 1000,
401
+ // The shared GID. Every container writes /workspace.
402
+ fsGroup: 1000,
403
+ seccompProfile: { type: "RuntimeDefault" },
404
+ },
405
+ containers,
406
+ volumes: watchVolumes(args),
407
+ },
408
+ };
409
+ }
410
+
411
+ function watchVolumes(args: BuildWatchPodArgs): V1Volume[] {
412
+ return [
413
+ { name: "workspace", emptyDir: {} },
414
+ // One cache volume, one subdirectory per app. Lives as long as the pod, so
415
+ // a module closure is downloaded once per app per session and every later
416
+ // reload resolves from local disk.
417
+ { name: "telo-cache", emptyDir: {} },
418
+ { name: "work", emptyDir: {} },
419
+ { name: "home", emptyDir: {} },
420
+ { name: "tmp", emptyDir: {} },
421
+ {
422
+ name: "workspace-app",
423
+ configMap: { name: args.workspaceAppConfigMap },
424
+ },
425
+ ];
426
+ }
427
+
428
+ /** Per-container scratch carved out of one volume by `subPath`, so N apps do not
429
+ * mean 3N pod volumes. */
430
+ function scratchMounts(owner: string): V1VolumeMount[] {
431
+ return [
432
+ { name: "work", mountPath: WORK_DIR, subPath: owner },
433
+ { name: "home", mountPath: HOME_DIR, subPath: owner },
434
+ { name: "tmp", mountPath: TMP_MOUNT, subPath: owner },
435
+ ];
436
+ }
437
+
438
+ function watchResources(limits: ResolvedLimits): Record<string, unknown> {
439
+ return {
440
+ limits: {
441
+ cpu: limits.cpu,
442
+ memory: limits.memory,
443
+ "ephemeral-storage": limits.ephemeralStorage,
444
+ },
445
+ requests: { cpu: limits.cpu, memory: limits.memory },
446
+ };
447
+ }
448
+
449
+ /**
450
+ * The workspace surface is RUNNER infrastructure, not agent functionality — it
451
+ * is part of the `/v1` session contract, so the runner owns it and the agent is
452
+ * one more writer on the volume beside the app containers. Hanging it off the
453
+ * catalog image would invert the dependency (the session contract resting on an
454
+ * application the operator configures) and would need a second implementation
455
+ * for the agentless case.
456
+ *
457
+ * It runs the plain kernel image over a manifest mounted from a ConfigMap, so
458
+ * there is no third image to build and publish.
459
+ */
460
+ function workspaceContainer(args: BuildWatchPodArgs): V1Container {
461
+ return {
462
+ name: "workspace",
463
+ image: args.image,
464
+ imagePullPolicy: pullPolicyToK8s(args.pullPolicy),
465
+ workingDir: WORK_DIR,
466
+ command: ["telo", "run", `${WORKSPACE_APP_DIR}/telo.yaml`],
467
+ env: [
468
+ { name: "PORT", value: String(WORKSPACE_PORT) },
469
+ { name: "WORKSPACE_DIR", value: WORKSPACE_DIR },
470
+ { name: "TELO_CACHE_DIR", value: `${CACHE_ROOT}/workspace` },
471
+ { name: "HOME", value: HOME_DIR },
472
+ { 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
+ ],
477
+ ports: [{ containerPort: WORKSPACE_PORT, protocol: "TCP" }],
478
+ resources: watchResources(args.limits),
479
+ volumeMounts: [
480
+ { name: "workspace", mountPath: WORKSPACE_DIR },
481
+ { name: "telo-cache", mountPath: CACHE_ROOT },
482
+ { name: "workspace-app", mountPath: WORKSPACE_APP_DIR, readOnly: true },
483
+ ...scratchMounts("workspace"),
484
+ ],
485
+ securityContext: hardenedContainerSecurity(),
486
+ };
487
+ }
488
+
489
+ function appContainer(args: BuildWatchPodArgs, app: BackendAppSpec, index: number): V1Container {
490
+ const env = [
491
+ ...Object.entries(args.env).map(([name, value]) => ({ name, value })),
492
+ // Deliberately NO `TELO_CACHE_DIR`: it OUTRANKS the workspace marker, and
493
+ // the marker is what puts every app's cache in one place. The kernel walks
494
+ // up from the entry manifest to `telo-workspace.yaml` (seeded at the
495
+ // workspace root when the session starts) and anchors `.telo` there, so two
496
+ // apps importing the same module resolve it once between them.
497
+ { name: "HOME", value: HOME_DIR },
498
+ { name: "npm_config_cache", value: `${HOME_DIR}/.npm` },
499
+ // Carried as env, not interpolated into the shell line below: a path that
500
+ // reached a command string could close a quote.
501
+ { name: "TELO_ENTRY", value: `${WORKSPACE_DIR}/${app.entryRelativePath}` },
502
+ { name: "TELO_INSPECT_ADDR", value: `0.0.0.0:${inspectPortFor(index)}` },
503
+ ];
504
+ // Only under a terminal. `streams` forces nothing OFF either: with no terminal
505
+ // the precedence order already resolves to no colour, and an explicit
506
+ // NO_COLOR would sit ABOVE an app's own `color: always` and suppress a
507
+ // decision worth observing.
508
+ if (app.io === "tty") env.push({ name: "CLICOLOR_FORCE", value: "1" });
509
+
510
+ const tty = app.io === "tty";
511
+ return {
512
+ name: `app-${app.name}`,
513
+ image: args.image,
514
+ imagePullPolicy: pullPolicyToK8s(args.pullPolicy),
515
+ workingDir: WORK_DIR,
516
+ // Wait for the entry manifest before starting. The workspace arrives over
517
+ // the workspace container's HTTP surface once the pod is up, so an app that
518
+ // exec'd immediately would fail its first load on a file that is about to
519
+ // exist and report a generation nobody caused. The same wait covers a
520
+ // resumed pod and an app added to the set before its files are written.
521
+ //
522
+ // `--watch` + `--inspect` compose: one inspect endpoint serves the whole
523
+ // watch session and each rebuilt kernel re-attaches to it, so a reload is a
524
+ // stop/start pair on ONE debug connection — which is exactly what the
525
+ // runner's generation counting reads. 0.0.0.0 (not the CLI's loopback
526
+ // default) lets the runner reach it across the pod network; the port is
527
+ // never published via Service or Ingress.
528
+ command: [
529
+ "sh",
530
+ "-c",
531
+ 'while [ ! -f "$TELO_ENTRY" ]; do sleep 0.2; done; ' +
532
+ 'exec telo run "$TELO_ENTRY" --watch --inspect "$TELO_INSPECT_ADDR" --no-open',
533
+ ],
534
+ env,
535
+ stdin: true,
536
+ stdinOnce: false,
537
+ tty,
538
+ ...(app.ports.length > 0
539
+ ? {
540
+ ports: app.ports.map((p) => ({
541
+ containerPort: p.port,
542
+ protocol: p.protocol.toUpperCase(),
543
+ })),
544
+ }
545
+ : {}),
546
+ resources: watchResources(args.limits),
547
+ // No `telo-cache` mount: an app's cache lives under the workspace volume,
548
+ // anchored by the marker at its root, which is what makes it one cache for
549
+ // the whole session rather than one per app.
550
+ volumeMounts: [
551
+ { name: "workspace", mountPath: WORKSPACE_DIR },
552
+ ...scratchMounts(app.name),
553
+ ],
554
+ securityContext: hardenedContainerSecurity(),
555
+ };
556
+ }
557
+
558
+ /**
559
+ * At most one agent per session, never per app: the agent's unit is the
560
+ * workspace — it edits files, it does not own a process — and two agents over
561
+ * one workspace would contend on the same files and split one conversation in
562
+ * half.
563
+ *
564
+ * This is the ONLY container that receives the operator env, and the only one
565
+ * whose write-path hardening is relaxed (an operator-curated image, exactly as
566
+ * an app session is today).
567
+ */
568
+ function agentContainer(args: BuildWatchPodArgs, agent: ResolvedRunnerApp): V1Container {
569
+ return {
570
+ name: "agent",
571
+ image: agent.image,
572
+ imagePullPolicy: pullPolicyToK8s(agent.pullPolicy),
573
+ env: [
574
+ ...Object.entries(agent.env).map(([name, value]) => ({ name, value })),
575
+ // The agent's own workspace IS the session's shared volume.
576
+ { name: "WORKSPACE_DIR", value: WORKSPACE_DIR },
577
+ { name: "CLICOLOR_FORCE", value: "1" },
578
+ ],
579
+ // Declared so the pod describes what it listens on; the Service selects the
580
+ // pod and every container shares its network namespace, so the agent needs
581
+ // no routing of its own beyond being in the session's port set.
582
+ ...(agent.port !== undefined
583
+ ? { ports: [{ containerPort: agent.port, protocol: "TCP" }] }
584
+ : {}),
585
+ resources: watchResources(args.limits),
586
+ volumeMounts: [
587
+ { name: "workspace", mountPath: WORKSPACE_DIR },
588
+ ...scratchMounts("agent"),
589
+ ],
590
+ securityContext: {
591
+ allowPrivilegeEscalation: false,
592
+ capabilities: { drop: ["ALL"] },
593
+ },
594
+ };
595
+ }
@@ -0,0 +1,143 @@
1
+ import type { V1ContainerState, V1ContainerStatus, V1Pod } from "@kubernetes/client-node";
2
+ import type { RunStatus } from "@telorun/runner-core";
3
+
4
+ import type { KubeClient } from "./client.js";
5
+
6
+ /**
7
+ * Reading a Pod's status — shared by the run-session and watch-session paths.
8
+ * Pure functions over the watch payload plus the one delete that both perform;
9
+ * extracted so the two lifecycles cannot disagree about what "failed" means or
10
+ * which container's exit code counts.
11
+ */
12
+
13
+ export function podStatus(obj: unknown): V1Pod["status"] | undefined {
14
+ return (obj as V1Pod | undefined)?.status;
15
+ }
16
+
17
+ export function podPhase(obj: unknown): string | undefined {
18
+ return podStatus(obj)?.phase;
19
+ }
20
+
21
+ /** A coming-up message for the studio feed while the Pod is still scheduling /
22
+ * pulling / delivering the body / creating the container; undefined once running. */
23
+ export function provisionMessage(obj: unknown): string | undefined {
24
+ const status = podStatus(obj);
25
+ if (status?.phase !== "Pending") return undefined;
26
+ const containers = [
27
+ ...(status.initContainerStatuses ?? []),
28
+ ...(status.containerStatuses ?? []),
29
+ ];
30
+ for (const cs of containers) {
31
+ const reason = cs.state?.waiting?.reason;
32
+ if (reason) return humanizeWaitReason(reason);
33
+ }
34
+ return "Scheduling";
35
+ }
36
+
37
+ function humanizeWaitReason(reason: string): string {
38
+ switch (reason) {
39
+ case "ContainerCreating":
40
+ return "Creating container";
41
+ case "PodInitializing":
42
+ return "Delivering application";
43
+ case "ErrImagePull":
44
+ case "ImagePullBackOff":
45
+ return "Pulling image";
46
+ default:
47
+ return reason;
48
+ }
49
+ }
50
+
51
+ export function terminalStatus(obj: unknown, userStopped: boolean): RunStatus {
52
+ if (userStopped) return { kind: "stopped" };
53
+ const phase = podPhase(obj);
54
+ if (phase === "Succeeded") return { kind: "exited", code: containerExitCode(obj) ?? 0 };
55
+ return { kind: "failed", message: podFailureMessage(obj) };
56
+ }
57
+
58
+ // Exit code of the main session container — used to report a clean exit.
59
+ export function containerExitCode(obj: unknown): number | null {
60
+ const term = podStatus(obj)?.containerStatuses?.[0]?.state?.terminated;
61
+ return typeof term?.exitCode === "number" ? term.exitCode : null;
62
+ }
63
+
64
+ const MAX_FAILURE_DETAIL = 500;
65
+
66
+ /**
67
+ * Builds an actionable failure message from a terminal Pod status. Init
68
+ * containers are inspected first: a failed init container leaves the main
69
+ * container unstarted, so reading only `containerStatuses` would fall through
70
+ * to the bare "pod failed". For prebuilt session pods the common failure is the
71
+ * main container itself (image pull, OOM, a non-zero exit).
72
+ */
73
+ export function podFailureMessage(obj: unknown): string {
74
+ const status = podStatus(obj);
75
+ const fromContainer = firstContainerProblem(status);
76
+ if (fromContainer) return fromContainer;
77
+ if (status?.message) return truncateDetail(status.message);
78
+ if (status?.reason) return status.reason;
79
+ return "pod failed";
80
+ }
81
+
82
+ function firstContainerProblem(status: V1Pod["status"] | undefined): string | undefined {
83
+ const groups: Array<[string, V1ContainerStatus[] | undefined]> = [
84
+ ["init container", status?.initContainerStatuses],
85
+ ["container", status?.containerStatuses],
86
+ ];
87
+ for (const [label, statuses] of groups) {
88
+ for (const cs of statuses ?? []) {
89
+ const problem = containerStateProblem(cs.state) ?? containerStateProblem(cs.lastState);
90
+ if (problem) return `${label} "${cs.name}" ${problem}`;
91
+ }
92
+ }
93
+ return undefined;
94
+ }
95
+
96
+ function containerStateProblem(state: V1ContainerState | undefined): string | undefined {
97
+ const term = state?.terminated;
98
+ if (term && term.exitCode !== 0) {
99
+ const reason = term.reason ? `${term.reason} ` : "";
100
+ const detail = term.message ? `: ${truncateDetail(term.message)}` : "";
101
+ return `failed: ${reason}(exit code ${term.exitCode ?? "unknown"})${detail}`;
102
+ }
103
+ const waiting = state?.waiting;
104
+ if (waiting?.reason && isBlockingWaitReason(waiting.reason)) {
105
+ const detail = waiting.message ? `: ${truncateDetail(waiting.message)}` : "";
106
+ return `waiting: ${waiting.reason}${detail}`;
107
+ }
108
+ return undefined;
109
+ }
110
+
111
+ // Benign transient reasons the kubelet reports while a Pod is still coming up.
112
+ function isBlockingWaitReason(reason: string): boolean {
113
+ return reason !== "PodInitializing" && reason !== "ContainerCreating";
114
+ }
115
+
116
+ function truncateDetail(text: string): string {
117
+ const trimmed = text.trim();
118
+ return trimmed.length > MAX_FAILURE_DETAIL ? `${trimmed.slice(0, MAX_FAILURE_DETAIL)}…` : trimmed;
119
+ }
120
+
121
+ export async function deletePod(kube: KubeClient, ns: string, name: string): Promise<void> {
122
+ try {
123
+ await kube.core.deleteNamespacedPod({ name, namespace: ns, gracePeriodSeconds: 0 });
124
+ } catch (err) {
125
+ // 404 = already gone (natural exit + GC). Anything else is a real failure.
126
+ if (!is404(err)) throw err;
127
+ }
128
+ }
129
+
130
+ 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;
133
+ }
134
+
135
+ export function msg(err: unknown): string {
136
+ if (err instanceof Error) return err.message;
137
+ if (typeof err === "string") return err;
138
+ try {
139
+ return JSON.stringify(err);
140
+ } catch {
141
+ return String(err);
142
+ }
143
+ }
@@ -0,0 +1,215 @@
1
+ import type { V1Container } from "@kubernetes/client-node";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { loadK8sRunnerConfig } from "../config.js";
5
+ import { buildSessionPod, buildWatchPod } from "./pod-spec.js";
6
+
7
+ const BASE_ENV = {
8
+ RUNNER_SELF_URL: "http://k8s-runner.telo-runner.svc:8062",
9
+ RUNNER_IMAGE_REPOSITORY: "registry.telo-runner.svc:5000/telo-sessions",
10
+ RUNNER_WATCH_SESSIONS: "true",
11
+ };
12
+
13
+ const config = loadK8sRunnerConfig({ ...process.env, ...BASE_ENV });
14
+
15
+ const OPERATOR_KEY = "OPENAI_API_KEY";
16
+ const OPERATOR_VALUE = "sk-operator-secret";
17
+
18
+ function build(overrides: Partial<Parameters<typeof buildWatchPod>[0]> = {}) {
19
+ return buildWatchPod({
20
+ config,
21
+ sessionId: "abc123",
22
+ podName: "telo-watch-abc123",
23
+ env: { APP_SETTING: "from-session" },
24
+ apps: [
25
+ { name: "web", entryRelativePath: "telo.yaml", ports: [{ port: 3000, protocol: "tcp" }], io: "tty" },
26
+ { name: "worker", entryRelativePath: "worker.yaml", ports: [], io: "streams" },
27
+ ],
28
+ agent: {
29
+ name: "authoring-agent",
30
+ image: "ghcr.io/telorun/authoring-agent:1",
31
+ env: { [OPERATOR_KEY]: OPERATOR_VALUE },
32
+ pullPolicy: "missing",
33
+ port: 8080,
34
+ },
35
+ limits: config.appLimits,
36
+ image: "telorun/node:latest-slim",
37
+ pullPolicy: "missing",
38
+ workspaceAppConfigMap: "telo-workspace-app-deadbeef",
39
+ ...overrides,
40
+ });
41
+ }
42
+
43
+ const byName = (pod: ReturnType<typeof build>, name: string): V1Container =>
44
+ pod.spec!.containers.find((c) => c.name === name)!;
45
+
46
+ const envOf = (c: V1Container): Record<string, string> =>
47
+ Object.fromEntries((c.env ?? []).map((e) => [e.name, e.value ?? ""]));
48
+
49
+ describe("buildWatchPod — the credential boundary", () => {
50
+ const pod = build();
51
+
52
+ it("puts the operator env on the agent container and nowhere else", () => {
53
+ // Structural before (two pods), a code invariant now (containers in one
54
+ // pod) — so it needs a test that names the property directly.
55
+ for (const container of pod.spec!.containers) {
56
+ const env = envOf(container);
57
+ if (container.name === "agent") {
58
+ expect(env[OPERATOR_KEY]).toBe(OPERATOR_VALUE);
59
+ } else {
60
+ expect(env).not.toHaveProperty(OPERATOR_KEY);
61
+ }
62
+ }
63
+ });
64
+
65
+ it("gives every app container the session env and the workspace container neither", () => {
66
+ expect(envOf(byName(pod, "app-web")).APP_SETTING).toBe("from-session");
67
+ expect(envOf(byName(pod, "app-worker")).APP_SETTING).toBe("from-session");
68
+ expect(envOf(byName(pod, "workspace"))).not.toHaveProperty("APP_SETTING");
69
+ expect(envOf(byName(pod, "workspace"))).not.toHaveProperty(OPERATOR_KEY);
70
+ });
71
+
72
+ it("roots the agent at the session's shared workspace", () => {
73
+ // The agent is one writer on the shared volume beside the app containers,
74
+ // not the owner of a directory inside its own container. Its manifest reads
75
+ // this to place every file tool and every spawned command.
76
+ expect(envOf(byName(pod, "agent")).WORKSPACE_DIR).toBe("/workspace");
77
+ expect(
78
+ byName(pod, "agent").volumeMounts?.find((m) => m.name === "workspace")?.mountPath,
79
+ ).toBe("/workspace");
80
+ });
81
+
82
+ it("runs at most one agent, whatever the app count", () => {
83
+ expect(pod.spec!.containers.filter((c) => c.name === "agent")).toHaveLength(1);
84
+ });
85
+
86
+ // The pod's containers share one network namespace, so declaring the port is
87
+ // all the agent needs to be reachable through the session's own Service —
88
+ // there is no routing of its own to arrange.
89
+ it("declares the agent's port so the session's Service can carry it", () => {
90
+ expect(byName(pod, "agent").ports).toEqual([{ containerPort: 8080, protocol: "TCP" }]);
91
+ });
92
+
93
+ it("declares no port for an agent whose catalog entry has none", () => {
94
+ const portless = build({
95
+ agent: {
96
+ name: "authoring-agent",
97
+ image: "ghcr.io/telorun/authoring-agent:1",
98
+ env: {},
99
+ pullPolicy: "missing",
100
+ },
101
+ });
102
+ expect(byName(portless, "agent").ports).toBeUndefined();
103
+ });
104
+
105
+ it("omits the agent container entirely when none was requested", () => {
106
+ const bare = build({ agent: undefined });
107
+ expect(bare.spec!.containers.map((c) => c.name)).toEqual([
108
+ "workspace",
109
+ "app-web",
110
+ "app-worker",
111
+ ]);
112
+ });
113
+ });
114
+
115
+ describe("buildWatchPod — the shared workspace", () => {
116
+ const pod = build();
117
+
118
+ it("shares one GID across every container", () => {
119
+ // Without this the agent writes files the app cannot read, which surfaces
120
+ // as a manifest that "does not exist" one reload after it was written.
121
+ expect(pod.spec!.securityContext?.fsGroup).toBe(1000);
122
+ for (const container of pod.spec!.containers) {
123
+ const mount = container.volumeMounts?.find((m) => m.name === "workspace");
124
+ expect(mount?.mountPath).toBe("/workspace");
125
+ expect(mount?.readOnly).toBeFalsy();
126
+ }
127
+ });
128
+
129
+ it("leaves every app's cache to the workspace marker", () => {
130
+ // `TELO_CACHE_DIR` OUTRANKS the marker, so setting it per app is exactly
131
+ // what would give each app its own cache. Without it the kernel walks up
132
+ // from the entry manifest to `telo-workspace.yaml` at the workspace root and
133
+ // anchors one `.telo` there for the whole session.
134
+ expect(envOf(byName(pod, "app-web"))).not.toHaveProperty("TELO_CACHE_DIR");
135
+ expect(envOf(byName(pod, "app-worker"))).not.toHaveProperty("TELO_CACHE_DIR");
136
+ for (const name of ["app-web", "app-worker"]) {
137
+ expect(byName(pod, name).volumeMounts?.map((m) => m.name)).not.toContain("telo-cache");
138
+ }
139
+ });
140
+
141
+ it("gives the workspace container an explicit cache root", () => {
142
+ // Its manifest lives OUTSIDE the workspace, so the walk-up from its entry
143
+ // would never reach the marker.
144
+ expect(envOf(byName(pod, "workspace")).TELO_CACHE_DIR).toBe("/telo-cache/workspace");
145
+ });
146
+
147
+ it("mounts the workspace application's manifest read-only, outside /workspace", () => {
148
+ const mount = byName(pod, "workspace").volumeMounts?.find((m) => m.name === "workspace-app");
149
+ expect(mount?.readOnly).toBe(true);
150
+ expect(mount?.mountPath).not.toContain("/workspace/");
151
+ expect(pod.spec!.volumes?.find((v) => v.name === "workspace-app")?.configMap?.name).toBe(
152
+ "telo-workspace-app-deadbeef",
153
+ );
154
+ });
155
+ });
156
+
157
+ describe("buildWatchPod — how each app runs", () => {
158
+ const pod = build();
159
+
160
+ it("keeps every app container's hardening identical to a run session's", () => {
161
+ const runPod = buildSessionPod({
162
+ config,
163
+ sessionId: "abc123",
164
+ podName: "telo-run-abc123",
165
+ entryRelativePath: "telo.yaml",
166
+ env: {},
167
+ ports: [],
168
+ limits: config.limits,
169
+ image: "prebuilt",
170
+ bundleUrl: "http://runner/bundle",
171
+ inspect: false,
172
+ });
173
+ const expected = runPod.spec!.containers[0].securityContext;
174
+ expect(byName(pod, "app-web").securityContext).toEqual(expected);
175
+ expect(byName(pod, "app-worker").securityContext).toEqual(expected);
176
+ });
177
+
178
+ it("waits for its entry manifest, then runs under --watch with a distinct inspect port", () => {
179
+ const web = envOf(byName(pod, "app-web"));
180
+ const worker = envOf(byName(pod, "app-worker"));
181
+ expect(web.TELO_ENTRY).toBe("/workspace/telo.yaml");
182
+ expect(worker.TELO_ENTRY).toBe("/workspace/worker.yaml");
183
+ // Containers in one pod share a network namespace, so a second bind on the
184
+ // same port would fail.
185
+ expect(web.TELO_INSPECT_ADDR).not.toBe(worker.TELO_INSPECT_ADDR);
186
+
187
+ const command = byName(pod, "app-web").command!.join(" ");
188
+ expect(command).toContain("while [ ! -f \"$TELO_ENTRY\" ]");
189
+ expect(command).toContain("--watch");
190
+ expect(command).toContain("--inspect");
191
+ // The path never reaches the shell line — a value that did could close a quote.
192
+ expect(command).not.toContain("/workspace/telo.yaml");
193
+ });
194
+
195
+ it("forces colour only under a terminal", () => {
196
+ expect(envOf(byName(pod, "app-web")).CLICOLOR_FORCE).toBe("1");
197
+ // `streams` exists to show what production sees; forcing colour there would
198
+ // defeat the mode by putting ANSI into what is meant to be a pipe.
199
+ expect(envOf(byName(pod, "app-worker"))).not.toHaveProperty("CLICOLOR_FORCE");
200
+ expect(byName(pod, "app-web").tty).toBe(true);
201
+ expect(byName(pod, "app-worker").tty).toBe(false);
202
+ });
203
+
204
+ it("takes the watch TTL, not the run-session one", () => {
205
+ expect(pod.spec!.activeDeadlineSeconds).toBe(config.watch.maxTtlSeconds);
206
+ });
207
+
208
+ it("labels the pod for orphan reaping and marks it as a watch session", () => {
209
+ expect(pod.metadata?.labels).toMatchObject({
210
+ "app.kubernetes.io/managed-by": "telo-k8s-runner",
211
+ "telo.run/session-id": "abc123",
212
+ "telo.run/mode": "watch",
213
+ });
214
+ });
215
+ });