@telorun/k8s-runner 0.13.0 → 0.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (118) hide show
  1. package/README.md +166 -107
  2. package/dist/bundle-store.d.ts +6 -13
  3. package/dist/bundle-store.d.ts.map +1 -1
  4. package/dist/bundle-store.js +6 -18
  5. package/dist/bundle-store.js.map +1 -1
  6. package/dist/capabilities.d.ts +3 -3
  7. package/dist/capabilities.js +4 -4
  8. package/dist/capabilities.js.map +1 -1
  9. package/dist/config.d.ts +47 -37
  10. package/dist/config.d.ts.map +1 -1
  11. package/dist/config.js +64 -25
  12. package/dist/config.js.map +1 -1
  13. package/dist/k8s/api-error.d.ts +34 -0
  14. package/dist/k8s/api-error.d.ts.map +1 -0
  15. package/dist/k8s/api-error.js +118 -0
  16. package/dist/k8s/api-error.js.map +1 -0
  17. package/dist/k8s/backend.d.ts +4 -0
  18. package/dist/k8s/backend.d.ts.map +1 -1
  19. package/dist/k8s/backend.js +77 -51
  20. package/dist/k8s/backend.js.map +1 -1
  21. package/dist/k8s/client.d.ts +7 -2
  22. package/dist/k8s/client.d.ts.map +1 -1
  23. package/dist/k8s/client.js +3 -2
  24. package/dist/k8s/client.js.map +1 -1
  25. package/dist/k8s/pod-spec.d.ts +8 -9
  26. package/dist/k8s/pod-spec.d.ts.map +1 -1
  27. package/dist/k8s/pod-spec.js +19 -29
  28. package/dist/k8s/pod-spec.js.map +1 -1
  29. package/dist/k8s/pod-status.d.ts +5 -0
  30. package/dist/k8s/pod-status.d.ts.map +1 -1
  31. package/dist/k8s/pod-status.js +9 -2
  32. package/dist/k8s/pod-status.js.map +1 -1
  33. package/dist/k8s/routing/create-or-replace.d.ts +5 -0
  34. package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
  35. package/dist/k8s/routing/create-or-replace.js +15 -0
  36. package/dist/k8s/routing/create-or-replace.js.map +1 -0
  37. package/dist/k8s/routing/gateway-router.d.ts +5 -0
  38. package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
  39. package/dist/k8s/routing/gateway-router.js +178 -0
  40. package/dist/k8s/routing/gateway-router.js.map +1 -0
  41. package/dist/k8s/routing/index.d.ts +24 -0
  42. package/dist/k8s/routing/index.d.ts.map +1 -0
  43. package/dist/k8s/routing/index.js +27 -0
  44. package/dist/k8s/routing/index.js.map +1 -0
  45. package/dist/k8s/routing/ingress-router.d.ts +13 -0
  46. package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
  47. package/dist/k8s/routing/ingress-router.js +125 -0
  48. package/dist/k8s/routing/ingress-router.js.map +1 -0
  49. package/dist/k8s/routing/route-health.d.ts +34 -0
  50. package/dist/k8s/routing/route-health.d.ts.map +1 -0
  51. package/dist/k8s/routing/route-health.js +77 -0
  52. package/dist/k8s/routing/route-health.js.map +1 -0
  53. package/dist/k8s/routing/routing-mode.d.ts +45 -0
  54. package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
  55. package/dist/k8s/routing/routing-mode.js +141 -0
  56. package/dist/k8s/routing/routing-mode.js.map +1 -0
  57. package/dist/k8s/routing/session-endpoints.d.ts +25 -0
  58. package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
  59. package/dist/k8s/routing/session-endpoints.js +36 -0
  60. package/dist/k8s/routing/session-endpoints.js.map +1 -0
  61. package/dist/k8s/routing/session-router.d.ts +57 -0
  62. package/dist/k8s/routing/session-router.d.ts.map +1 -0
  63. package/dist/k8s/routing/session-router.js +2 -0
  64. package/dist/k8s/routing/session-router.js.map +1 -0
  65. package/dist/k8s/routing/session-service.d.ts +14 -0
  66. package/dist/k8s/routing/session-service.d.ts.map +1 -0
  67. package/dist/k8s/routing/session-service.js +42 -0
  68. package/dist/k8s/routing/session-service.js.map +1 -0
  69. package/dist/k8s/watch-session.d.ts +7 -5
  70. package/dist/k8s/watch-session.d.ts.map +1 -1
  71. package/dist/k8s/watch-session.js +90 -83
  72. package/dist/k8s/watch-session.js.map +1 -1
  73. package/dist/k8s/workspace-configmap.d.ts.map +1 -1
  74. package/dist/k8s/workspace-configmap.js +3 -12
  75. package/dist/k8s/workspace-configmap.js.map +1 -1
  76. package/dist/server.d.ts.map +1 -1
  77. package/dist/server.js +37 -4
  78. package/dist/server.js.map +1 -1
  79. package/package.json +2 -2
  80. package/src/bundle-store.ts +6 -19
  81. package/src/capabilities.ts +4 -4
  82. package/src/config.ts +130 -68
  83. package/src/k8s/api-error.test.ts +89 -0
  84. package/src/k8s/api-error.ts +128 -0
  85. package/src/k8s/backend.ts +103 -58
  86. package/src/k8s/client.ts +10 -3
  87. package/src/k8s/pod-spec.test.ts +57 -2
  88. package/src/k8s/pod-spec.ts +25 -36
  89. package/src/k8s/pod-status.ts +10 -2
  90. package/src/k8s/routing/create-or-replace.ts +16 -0
  91. package/src/k8s/routing/gateway-router.test.ts +236 -0
  92. package/src/k8s/routing/gateway-router.ts +219 -0
  93. package/src/k8s/routing/index.ts +44 -0
  94. package/src/k8s/routing/ingress-router.test.ts +236 -0
  95. package/src/k8s/routing/ingress-router.ts +157 -0
  96. package/src/k8s/routing/route-health.test.ts +215 -0
  97. package/src/k8s/routing/route-health.ts +95 -0
  98. package/src/k8s/routing/routing-mode.test.ts +218 -0
  99. package/src/k8s/routing/routing-mode.ts +191 -0
  100. package/src/k8s/routing/session-endpoints.ts +45 -0
  101. package/src/k8s/routing/session-router.ts +59 -0
  102. package/src/k8s/routing/session-service.ts +55 -0
  103. package/src/k8s/watch-pod-spec.test.ts +0 -1
  104. package/src/k8s/watch-session.ts +90 -77
  105. package/src/k8s/workspace-configmap.ts +3 -15
  106. package/src/server.ts +41 -4
  107. package/dist/k8s/image-build.d.ts +0 -103
  108. package/dist/k8s/image-build.d.ts.map +0 -1
  109. package/dist/k8s/image-build.js +0 -427
  110. package/dist/k8s/image-build.js.map +0 -1
  111. package/dist/k8s/ingress.d.ts +0 -15
  112. package/dist/k8s/ingress.d.ts.map +0 -1
  113. package/dist/k8s/ingress.js +0 -102
  114. package/dist/k8s/ingress.js.map +0 -1
  115. package/src/k8s/image-build.test.ts +0 -238
  116. package/src/k8s/image-build.ts +0 -540
  117. package/src/k8s/ingress.test.ts +0 -146
  118. package/src/k8s/ingress.ts +0 -127
@@ -23,8 +23,17 @@ 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
- import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
28
+ import {
29
+ buildSessionService,
30
+ createOrReplace,
31
+ endpointsFor,
32
+ sessionObjectName,
33
+ watchRouteHealth,
34
+ type PublishedRoute,
35
+ type SessionRouter,
36
+ } from "./routing/index.js";
28
37
  import { buildWatchPod, inspectPortFor, WORKSPACE_PORT } from "./pod-spec.js";
29
38
  import { deletePod, is404, msg, podPhase, podStatus, provisionMessage } from "./pod-status.js";
30
39
  import { ensureWorkspaceConfigMap } from "./workspace-configmap.js";
@@ -48,23 +57,24 @@ interface ResizableSocket {
48
57
  export interface WatchSessionDeps {
49
58
  kube: KubeClient;
50
59
  config: K8sRunnerConfig;
60
+ /** Resolved at boot; absent → logs-only. */
61
+ router?: SessionRouter;
51
62
  }
52
63
 
53
64
  /**
54
65
  * A watch session: one pod, one workspace volume, one container per running
55
66
  * application, and the session outliving every run inside it.
56
67
  *
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.
68
+ * Its cache is what separates it from a run session: the module closure lands in
69
+ * a directory that lives as long as the POD rather than as long as one run, so
70
+ * the download happens once per session and every later reload resolves from
71
+ * local disk.
62
72
  */
63
73
  export async function startWatchSession(
64
74
  deps: WatchSessionDeps,
65
75
  spec: BackendStartSpec,
66
76
  ): Promise<BackendSession> {
67
- const { kube, config } = deps;
77
+ const { kube, config, router } = deps;
68
78
  const ns = config.sessionNamespace;
69
79
  const limits = clampLimits(config.appLimits, undefined);
70
80
 
@@ -86,6 +96,9 @@ export async function startWatchSession(
86
96
  stdins: Map<string, PassThrough>;
87
97
  abort: AbortController;
88
98
  stopWatch: () => void;
99
+ /** Aborts the previous route-health watch when a reload republishes routes —
100
+ * the old host set is no longer what the session serves. */
101
+ routeAbort?: AbortController;
89
102
  }
90
103
  let runtime: PodRuntime | null = null;
91
104
 
@@ -117,12 +130,7 @@ export async function startWatchSession(
117
130
  try {
118
131
  created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
119
132
  } catch (err) {
120
- throw new SessionStartError(
121
- "start_failed",
122
- "create",
123
- `failed to create pod: ${msg(err)}`,
124
- msg(err),
125
- );
133
+ throw apiFailure(err, "create", "could not create the session pod");
126
134
  }
127
135
 
128
136
  const abort = new AbortController();
@@ -171,22 +179,22 @@ export async function startWatchSession(
171
179
  const agent = agentEndpoint();
172
180
  spec.onStatus({
173
181
  kind: "running",
174
- endpoints: endpointsFor(config, spec.sessionId, allPorts(apps)),
182
+ endpoints: endpointsFor(config, spec.sessionId, allPorts(apps), Boolean(router)),
175
183
  ...(agent ? { agent } : {}),
176
184
  });
177
185
  }
178
186
 
179
187
  /** Where this session's co-resident agent answers. The pod's containers share
180
188
  * one network namespace, so the agent rides the session's own Service and
181
- * Ingress — the port is simply in the routed set, and the host follows the
189
+ * routing objects — the port is simply in the routed set, and the host follows the
182
190
  * same `<port>-<sessionId>` scheme every app port does. */
183
191
  function agentEndpoint(): RunnerEndpoint | undefined {
184
192
  const port = spec.agent?.port;
185
193
  if (port === undefined) return undefined;
186
- return endpointsFor(config, spec.sessionId, [{ port, protocol: "tcp" }])[0];
194
+ return endpointsFor(config, spec.sessionId, [{ port, protocol: "tcp" }], Boolean(router))[0];
187
195
  }
188
196
 
189
- /** Every port the session's Service and Ingress must carry: the apps' declared
197
+ /** Every port the session's Service and routing objects must carry: the apps' declared
190
198
  * ports plus the agent's. Kept apart from `allPorts`, which answers what the
191
199
  * APPLICATIONS declare — that set is what the client is told about and what a
192
200
  * reload re-reads, and the agent belongs in neither. */
@@ -299,7 +307,7 @@ export async function startWatchSession(
299
307
  // channel rather than aborting the start.
300
308
  spec.onOutput(
301
309
  app.name,
302
- Buffer.from(`\r\n[runner] failed to attach: ${msg(err)}\r\n`),
310
+ Buffer.from(`\r\n[runner] failed to attach: ${apiReason(err)}\r\n`),
303
311
  tagFor(app, "stderr"),
304
312
  );
305
313
  }
@@ -327,8 +335,8 @@ export async function startWatchSession(
327
335
  * which matters, because a manifest the runner could not parse would otherwise
328
336
  * have to leave routing alone and report, on the hot path of every save.
329
337
  *
330
- * A pod's `containerPort` list is documentation; the Service and the Ingress
331
- * are what make a port reachable, so this needs no pod recreate.
338
+ * A pod's `containerPort` list is documentation; the Service and the routing
339
+ * objects are what make a port reachable, so this needs no pod recreate.
332
340
  */
333
341
  async function applyPortsResolved(appName: string, frame: DebugFrame): Promise<void> {
334
342
  const declared = portsResolvedFrom(frame);
@@ -364,16 +372,16 @@ export async function startWatchSession(
364
372
  await publishEndpoints(rt, apps);
365
373
  } catch (err) {
366
374
  spec.onEndpoints(appName, {
367
- rejected: accepted.map((p) => ({ port: p.port, reason: msg(err) })),
375
+ rejected: accepted.map((p) => ({ port: p.port, reason: apiReason(err) })),
368
376
  });
369
377
  return;
370
378
  }
371
379
  spec.onEndpoints(appName, {
372
380
  ...(accepted.length > 0
373
- ? { added: endpointsFor(config, spec.sessionId, accepted) }
381
+ ? { added: endpointsFor(config, spec.sessionId, accepted, Boolean(router)) }
374
382
  : {}),
375
383
  ...(removed.length > 0
376
- ? { removed: endpointsFor(config, spec.sessionId, removed) }
384
+ ? { removed: endpointsFor(config, spec.sessionId, removed, Boolean(router)) }
377
385
  : {}),
378
386
  ...(rejected.length > 0
379
387
  ? {
@@ -493,21 +501,25 @@ export async function startWatchSession(
493
501
  }
494
502
 
495
503
  /**
496
- * Create or re-patch the Service and Ingress for the session's whole declared
497
- * port set. Adding a `ports:` entry is as ordinary an edit as adding an
498
- * import, and a container may bind any port regardless of what the pod spec
499
- * declares — so without this the app listens and is simply unreachable: no
500
- * ingress, no error, no event. The pod's `containerPort` list is
501
- * documentation; the Service and Ingress are what make a port reachable, which
502
- * is why this needs no pod recreate.
504
+ * Create or re-patch the Service and the routing objects for the session's
505
+ * whole declared port set. Adding a `ports:` entry is as ordinary an edit as
506
+ * adding an import, and a container may bind any port regardless of what the
507
+ * pod spec declares — so without this the app listens and is simply
508
+ * unreachable: no route, no error, no event. The pod's `containerPort` list is
509
+ * documentation; the Service and the routing objects are what make a port
510
+ * reachable, which is why this needs no pod recreate.
511
+ *
512
+ * An EMPTY routed set still reaches the router, because dropping the last
513
+ * declared port is a reload like any other and its routes must go with it —
514
+ * returning early here left them serving a port nothing listens on.
503
515
  */
504
516
  async function publishEndpoints(rt: PodRuntime, forApps: BackendAppSpec[]): Promise<void> {
505
- if (!config.sessionIngressBaseDomain) return;
517
+ if (!router) return;
506
518
  const ports = routedPorts(forApps);
507
- if (ports.length === 0) return;
508
- const service = buildSessionService(config, spec.sessionId, rt.name, rt.uid, ports);
509
- const serviceName = service.metadata!.name!;
510
- await upsert(
519
+ const serviceName = sessionObjectName(spec.sessionId);
520
+ if (ports.length > 0) {
521
+ const service = buildSessionService(config, spec.sessionId, rt.name, rt.uid, ports);
522
+ await createOrReplace(
511
523
  () => kube.core.createNamespacedService({ namespace: ns, body: service }),
512
524
  async () => {
513
525
  // A Service replace must carry the assigned `clusterIP` and the current
@@ -532,37 +544,51 @@ export async function startWatchSession(
532
544
  },
533
545
  });
534
546
  },
535
- );
536
- const { ingress } = buildSessionIngress(
537
- config,
538
- spec.sessionId,
547
+ );
548
+ }
549
+ const published = await router.publish({
550
+ sessionId: spec.sessionId,
539
551
  serviceName,
540
- rt.name,
541
- rt.uid,
552
+ podName: rt.name,
553
+ podUid: rt.uid,
542
554
  ports,
543
- );
544
- if (!ingress.spec?.rules?.length) return;
545
- const ingressName = ingress.metadata!.name!;
546
- await upsert(
547
- () => kube.networking.createNamespacedIngress({ namespace: ns, body: ingress }),
548
- async () => {
549
- const existing = await kube.networking.readNamespacedIngress({
550
- name: ingressName,
551
- namespace: ns,
552
- });
553
- await kube.networking.replaceNamespacedIngress({
554
- name: ingressName,
555
- namespace: ns,
556
- body: {
557
- ...ingress,
558
- metadata: {
559
- ...ingress.metadata,
560
- resourceVersion: existing.metadata?.resourceVersion,
561
- },
562
- },
563
- });
564
- },
565
- );
555
+ });
556
+ watchRoutes(rt, router, published, forApps);
557
+ }
558
+
559
+ /** Verify the routing layer programmed what was just published, and report each
560
+ * host. A watch session republishes on every reload, so the previous watch is
561
+ * dropped and a fresh one started against the new host set. */
562
+ function watchRoutes(
563
+ rt: PodRuntime,
564
+ sessionRouter: SessionRouter,
565
+ published: PublishedRoute[],
566
+ forApps: BackendAppSpec[],
567
+ ): void {
568
+ rt.routeAbort?.abort();
569
+ if (published.length === 0) return;
570
+ const abort = new AbortController();
571
+ rt.routeAbort = abort;
572
+ // A port belongs to at most one app (the session-wide uniqueness rule); the
573
+ // co-resident agent's port belongs to none, and reports with no `app`.
574
+ const ownerOf = new Map<number, string>();
575
+ for (const app of forApps) {
576
+ for (const p of app.ports) ownerOf.set(p.port, app.name);
577
+ }
578
+ void watchRouteHealth({
579
+ router: sessionRouter,
580
+ sessionId: spec.sessionId,
581
+ routes: published,
582
+ onState: (route, state, reason) =>
583
+ spec.onRoute(ownerOf.get(route.port), {
584
+ host: route.host,
585
+ port: route.port,
586
+ state,
587
+ reason,
588
+ }),
589
+ signal: AbortSignal.any([abort.signal, rt.abort.signal]),
590
+ timeoutMs: config.sessionRouting.routeReadyTimeoutMs,
591
+ });
566
592
  }
567
593
 
568
594
  async function teardownPod(): Promise<void> {
@@ -644,14 +670,6 @@ export async function startWatchSession(
644
670
  return app.io === "tty" ? "tty" : real;
645
671
  }
646
672
 
647
- async function upsert(create: () => Promise<unknown>, replace: () => Promise<unknown>) {
648
- try {
649
- await create();
650
- } catch (err) {
651
- if (!isConflict(err)) throw err;
652
- await replace();
653
- }
654
- }
655
673
  }
656
674
 
657
675
  function allPorts(apps: BackendAppSpec[]): PortMapping[] {
@@ -673,11 +691,6 @@ function freshPodName(sessionId: string): string {
673
691
  return `telo-watch-${sessionId}-${Date.now().toString(36)}${podSequence.toString(36)}`;
674
692
  }
675
693
 
676
- function isConflict(err: unknown): boolean {
677
- const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
678
- return (e?.statusCode ?? e?.code ?? e?.response?.statusCode) === 409;
679
- }
680
-
681
694
  function sleep(ms: number, signal?: AbortSignal): Promise<void> {
682
695
  return new Promise((resolve, reject) => {
683
696
  const timer = setTimeout(resolve, ms);
@@ -4,6 +4,7 @@ import { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "@telorun/runner-co
4
4
 
5
5
  import type { K8sRunnerConfig } from "../config.js";
6
6
  import type { KubeClient } from "./client.js";
7
+ import { is404, isConflict } from "./pod-status.js";
7
8
 
8
9
  /**
9
10
  * The workspace application's manifest, delivered to the pod as a ConfigMap.
@@ -39,7 +40,7 @@ export async function ensureWorkspaceConfigMap(
39
40
  await kube.core.readNamespacedConfigMap({ name, namespace: config.sessionNamespace });
40
41
  return name;
41
42
  } catch (err) {
42
- if (!isNotFound(err)) throw err;
43
+ if (!is404(err)) throw err;
43
44
  }
44
45
  try {
45
46
  await kube.core.createNamespacedConfigMap({
@@ -64,19 +65,6 @@ export async function ensureWorkspaceConfigMap(
64
65
  return name;
65
66
  }
66
67
 
67
- function statusOf(err: unknown): number | undefined {
68
- const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
69
- return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
70
- }
71
-
72
- function isNotFound(err: unknown): boolean {
73
- return statusOf(err) === 404;
74
- }
75
-
76
- function isConflict(err: unknown): boolean {
77
- return statusOf(err) === 409;
78
- }
79
-
80
68
  /**
81
69
  * Delete workspace-app ConfigMaps no live pod mounts.
82
70
  *
@@ -126,7 +114,7 @@ export async function sweepWorkspaceConfigMaps(
126
114
  await kube.core.deleteNamespacedConfigMap({ name, namespace: config.sessionNamespace });
127
115
  removed += 1;
128
116
  } catch (err) {
129
- if (!isNotFound(err)) log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
117
+ if (!is404(err)) log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
130
118
  }
131
119
  }
132
120
  if (removed > 0) log.info({ removed }, "swept stale workspace-app ConfigMaps");
package/src/server.ts CHANGED
@@ -16,6 +16,7 @@ import { kubernetesRunnerCapabilities } from "./capabilities.js";
16
16
  import { loadK8sRunnerConfig, RunnerConfigError, type K8sRunnerConfig } from "./config.js";
17
17
  import { createKubernetesBackend } from "./k8s/backend.js";
18
18
  import { createKubeClient } from "./k8s/client.js";
19
+ import { createSessionRouter } from "./k8s/routing/index.js";
19
20
  import { sweepWorkspaceConfigMaps } from "./k8s/workspace-configmap.js";
20
21
 
21
22
  const VERSION: string = packageJson.version;
@@ -54,7 +55,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
54
55
  agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
55
56
  }),
56
57
  // Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
57
- // /v1/capabilities; app sessions run the catalog image directly (no build).
58
+ // /v1/capabilities; app sessions run the catalog image directly.
58
59
  apps,
59
60
  validateConfig: catalog
60
61
  ? (sessionConfig: SessionConfig): string | undefined =>
@@ -64,8 +65,8 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
64
65
  `Allowed images: ${catalog.current().join(", ")}`
65
66
  : undefined,
66
67
  });
67
- // Mount the internal, tokenized fetch route on the same app so a build Job's
68
- // initContainer can pull the build-context tarball (bundle + Dockerfile).
68
+ // Mount the internal, tokenized fetch route on the same app so a session pod's
69
+ // initContainer can pull the bundle tarball.
69
70
  deps.bundleStore.registerRoute(handle.app);
70
71
  return handle;
71
72
  }
@@ -84,7 +85,22 @@ async function main(): Promise<void> {
84
85
 
85
86
  const kube = createKubeClient();
86
87
  const bundleStore = new BundleStore(config.selfUrl);
87
- const backend = createKubernetesBackend({ kube, config, bundleStore });
88
+
89
+ // Resolve the routing layer ONCE, here: an unroutable configuration must fail
90
+ // the runner rather than every session it would otherwise accept, publish
91
+ // routes for, and leave 404-ing with nothing reported.
92
+ let routing: Awaited<ReturnType<typeof createSessionRouter>>;
93
+ try {
94
+ routing = await createSessionRouter(kube, config);
95
+ } catch (err) {
96
+ if (err instanceof RunnerConfigError) {
97
+ process.stderr.write(`${err.message}\n`);
98
+ process.exit(2);
99
+ }
100
+ throw err;
101
+ }
102
+
103
+ const backend = createKubernetesBackend({ kube, config, bundleStore, router: routing.router });
88
104
 
89
105
  const catalog = config.baseImageCatalog.enabled
90
106
  ? new BaseImageCatalog({
@@ -98,6 +114,27 @@ async function main(): Promise<void> {
98
114
 
99
115
  const { app, registry } = await buildServer({ backend, config, bundleStore, catalog });
100
116
 
117
+ // Say which layer carries session traffic. An operator reading "logs-only" here
118
+ // is the difference between a misconfiguration found at boot and one found as a
119
+ // 404 on a URL the editor handed a user.
120
+ if (routing.resolved.layer === "none") {
121
+ app.log.warn({ reason: routing.resolved.reason }, "session routing disabled (logs-only)");
122
+ } else {
123
+ app.log.info(
124
+ {
125
+ layer: routing.resolved.layer,
126
+ ...(routing.resolved.layer === "gateway"
127
+ ? {
128
+ apiVersion: routing.resolved.apiVersion,
129
+ gateway: `${config.sessionRouting.gateway?.namespace}/${config.sessionRouting.gateway?.name}`,
130
+ }
131
+ : { ingressClass: config.sessionRouting.ingressClassName ?? "(cluster default)" }),
132
+ baseDomain: config.sessionRouting.baseDomain,
133
+ },
134
+ "session routing resolved",
135
+ );
136
+ }
137
+
101
138
  // Populate the catalog before serving so the first /v1/capabilities carries the
102
139
  // full menu; a fetch failure degrades to the default image (surfaced, not
103
140
  // swallowed) and the periodic refresh retries.
@@ -1,103 +0,0 @@
1
- import type { V1Job } from "@kubernetes/client-node";
2
- import type { PullPolicy, RunBundle } from "@telorun/runner-core";
3
- import type { ImageBuildConfig } from "../config.js";
4
- import type { BundleStore } from "../bundle-store.js";
5
- import type { KubeClient } from "./client.js";
6
- export interface ImageTagInputs {
7
- bundle: RunBundle;
8
- /** Base image the per-app image is built FROM. */
9
- baseImage: string;
10
- /** Resolved digest of `baseImage`, folded into the tag so a moved moving-tag
11
- * (e.g. `latest-slim`) yields a fresh tag and rebuilds. Set only under the
12
- * `always` pull policy; omitted → keyed on the base TAG STRING alone. */
13
- baseDigest?: string;
14
- }
15
- /**
16
- * Content-address the per-app image on its DEPENDENCY closure — the only thing
17
- * `telo install` bakes into `.telo/{manifests,npm}`: the `imports:` set plus any
18
- * controllers declared by inline `Telo.Definition` docs (`extractDependencyKey`).
19
- * A body-only edit (resource config, CEL) keeps the tag stable so the image is
20
- * reused and the new body is delivered to the pod at runtime; an import or
21
- * controller change produces a fresh tag and rebuilds. The entry path is
22
- * deliberately excluded — it's supplied to `telo run` at runtime, and the same
23
- * imports resolve the same closure regardless of the entry filename.
24
- *
25
- * `local_path` controllers (or an unparseable file) set `fullContentFallback`,
26
- * which folds the whole bundle into the digest — keying degrades to today's
27
- * content-address, which is correct (those depend on body bytes) if less optimal.
28
- */
29
- export declare function computeImageTag(inputs: ImageTagInputs): string;
30
- export declare function imageRef(config: ImageBuildConfig, tag: string): string;
31
- /**
32
- * Dockerfile for a self-contained per-app image: `telo install` populates
33
- * `/telo-cache/{manifests,npm}` so the runtime `telo run` resolves every
34
- * controller and module from disk with no network and no install. The telo
35
- * registry arrives as a build-arg so the build (not the running session)
36
- * carries the registry dependency.
37
- *
38
- * `TELO_CACHE_DIR=/telo-cache` relocates the baked `.telo` OFF `/app`, so at
39
- * runtime the pod mounts the freshly-delivered body at `/app` without shadowing
40
- * the deps. The deps depend only on the imports/controllers closure (see
41
- * `computeImageTag`); the body COPYed here is whichever session first built this
42
- * tag — disposable, replaced at boot.
43
- */
44
- export declare function buildDockerfile(opts: {
45
- baseImage: string;
46
- entryRelativePath: string;
47
- }): string;
48
- export interface KanikoJobArgs {
49
- config: ImageBuildConfig;
50
- jobName: string;
51
- /** Tokenized URL the initContainer fetches the build context (bundle + Dockerfile) from. */
52
- contextUrl: string;
53
- /** Fully-qualified image ref Kaniko pushes. */
54
- destination: string;
55
- /** Small image (wget + tar) for the context-fetch initContainer. */
56
- initImage: string;
57
- managedByLabel: string;
58
- }
59
- /**
60
- * Builds the Kaniko Job. An initContainer fetches the tokenized context tarball
61
- * into a shared emptyDir; Kaniko builds it and pushes to the registry. The Job
62
- * is single-attempt (`backoffLimit: 0`), deadline-bounded, and self-cleaning
63
- * (`ttlSecondsAfterFinished`). Kaniko needs a writable rootfs, so this runs in
64
- * the trusted `telo-builds` namespace at baseline PodSecurity — never alongside
65
- * untrusted session pods.
66
- */
67
- export declare function buildKanikoJob(args: KanikoJobArgs): V1Job;
68
- export interface EnsureImageDeps {
69
- kube: KubeClient;
70
- build: ImageBuildConfig;
71
- bundleStore: BundleStore;
72
- initImage: string;
73
- managedByLabel: string;
74
- }
75
- export interface EnsureImageArgs {
76
- bundle: RunBundle;
77
- entryRelativePath: string;
78
- baseImage: string;
79
- /** Base-image freshness. `always` pins the per-app image to the base's current
80
- * Docker Hub digest so a moved moving-tag rebuilds; `missing` / `never` (and
81
- * `always` on a base whose digest can't be resolved) reuse the cached build. */
82
- pullPolicy: PullPolicy;
83
- /** Build-phase progress for the editor feed. */
84
- onProgress?: (message: string, done?: boolean) => void;
85
- }
86
- /**
87
- * Resolve the per-app image, building it on-cluster if absent. Returns the image
88
- * ref to run; throws `SessionStartError` (stage `create`) with the build pod's
89
- * log tail on failure so the cause reaches the editor instead of an opaque hang.
90
- */
91
- export declare function ensureSessionImage(deps: EnsureImageDeps, args: EnsureImageArgs): Promise<string>;
92
- /** Registry pull credentials decoded from a `.dockerconfigjson` Secret. */
93
- export interface RegistryAuth {
94
- username: string;
95
- password: string;
96
- }
97
- /** Extract credentials for `repository`'s host from a `.dockerconfigjson` payload. */
98
- export declare function parseDockerConfigAuth(dockerconfigjson: string, repository: string): RegistryAuth | undefined;
99
- /** Host portion of a registry ref / dockerconfig key (scheme + path stripped). */
100
- export declare function registryHost(ref: string): string;
101
- /** Parse `key="value"` pairs out of a `WWW-Authenticate: Bearer ...` challenge. */
102
- export declare function parseAuthParams(challenge: string): Record<string, string>;
103
- //# sourceMappingURL=image-build.d.ts.map
@@ -1 +0,0 @@
1
- {"version":3,"file":"image-build.d.ts","sourceRoot":"","sources":["../../src/k8s/image-build.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,yBAAyB,CAAC;AACrD,OAAO,KAAK,EAAE,UAAU,EAAE,SAAS,EAAE,MAAM,sBAAsB,CAAC;AAGlE,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,cAAc,CAAC;AACrD,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;AACtD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAO9C,MAAM,WAAW,cAAc;IAC7B,MAAM,EAAE,SAAS,CAAC;IAClB,kDAAkD;IAClD,SAAS,EAAE,MAAM,CAAC;IAClB;;8EAE0E;IAC1E,UAAU,CAAC,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;;;;;GAaG;AACH,wBAAgB,eAAe,CAAC,MAAM,EAAE,cAAc,GAAG,MAAM,CAkB9D;AAED,wBAAgB,QAAQ,CAAC,MAAM,EAAE,gBAAgB,EAAE,GAAG,EAAE,MAAM,GAAG,MAAM,CAEtE;AAED;;;;;;;;;;;;GAYG;AACH,wBAAgB,eAAe,CAAC,IAAI,EAAE;IAAE,SAAS,EAAE,MAAM,CAAC;IAAC,iBAAiB,EAAE,MAAM,CAAA;CAAE,GAAG,MAAM,CAS9F;AAED,MAAM,WAAW,aAAa;IAC5B,MAAM,EAAE,gBAAgB,CAAC;IACzB,OAAO,EAAE,MAAM,CAAC;IAChB,4FAA4F;IAC5F,UAAU,EAAE,MAAM,CAAC;IACnB,+CAA+C;IAC/C,WAAW,EAAE,MAAM,CAAC;IACpB,oEAAoE;IACpE,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,IAAI,EAAE,aAAa,GAAG,KAAK,CA6EzD;AAED,MAAM,WAAW,eAAe;IAC9B,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,gBAAgB,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB,SAAS,EAAE,MAAM,CAAC;IAClB,cAAc,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,SAAS,CAAC;IAClB,iBAAiB,EAAE,MAAM,CAAC;IAC1B,SAAS,EAAE,MAAM,CAAC;IAClB;;qFAEiF;IACjF,UAAU,EAAE,UAAU,CAAC;IACvB,gDAAgD;IAChD,UAAU,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,OAAO,KAAK,IAAI,CAAC;CACxD;AAQD;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,IAAI,EAAE,eAAe,EACrB,IAAI,EAAE,eAAe,GACpB,OAAO,CAAC,MAAM,CAAC,CAmCjB;AAuGD,2EAA2E;AAC3E,MAAM,WAAW,YAAY;IAC3B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;CAClB;AA6FD,sFAAsF;AACtF,wBAAgB,qBAAqB,CACnC,gBAAgB,EAAE,MAAM,EACxB,UAAU,EAAE,MAAM,GACjB,YAAY,GAAG,SAAS,CAoB1B;AAED,kFAAkF;AAClF,wBAAgB,YAAY,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,CAEhD;AAED,mFAAmF;AACnF,wBAAgB,eAAe,CAAC,SAAS,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAMzE"}