@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
@@ -0,0 +1,218 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { K8sRunnerConfig, SessionRoutingConfig } from "../../config.js";
4
+ import { RunnerConfigError } from "../../config.js";
5
+ import type { KubeClient } from "../client.js";
6
+ import { resolveRouting } from "./routing-mode.js";
7
+
8
+ /** A cluster: which Gateway API versions it serves, and how many IngressClasses
9
+ * exist. Those are the two signals `auto` may fall back on. */
10
+ function fakeKube(cluster: {
11
+ gatewayVersions?: string[];
12
+ ingressClasses?: number;
13
+ defaultIngressClass?: boolean;
14
+ }): KubeClient {
15
+ return {
16
+ apis: {
17
+ getAPIVersions: async () => ({
18
+ groups: cluster.gatewayVersions
19
+ ? [
20
+ {
21
+ name: "gateway.networking.k8s.io",
22
+ versions: cluster.gatewayVersions.map((version) => ({ version })),
23
+ preferredVersion: { version: cluster.gatewayVersions[0] },
24
+ },
25
+ ]
26
+ : [],
27
+ }),
28
+ },
29
+ networking: {
30
+ listIngressClass: async () => ({
31
+ items: Array.from({ length: cluster.ingressClasses ?? 0 }, (_unused, i) => ({
32
+ metadata:
33
+ cluster.defaultIngressClass && i === 0
34
+ ? { annotations: { "ingressclass.kubernetes.io/is-default-class": "true" } }
35
+ : {},
36
+ })),
37
+ }),
38
+ },
39
+ } as unknown as KubeClient;
40
+ }
41
+
42
+ function configWith(routing: Partial<SessionRoutingConfig>): K8sRunnerConfig {
43
+ return {
44
+ sessionNamespace: "telo-sessions",
45
+ sessionRouting: {
46
+ mode: "auto",
47
+ baseDomain: "telo.run",
48
+ routeReadyTimeoutMs: 60_000,
49
+ ...routing,
50
+ },
51
+ } as K8sRunnerConfig;
52
+ }
53
+
54
+ describe("resolveRouting — logs-only", () => {
55
+ it("is none when the mode says so", async () => {
56
+ const resolved = await resolveRouting(fakeKube({}), configWith({ mode: "none" }));
57
+ expect(resolved).toMatchObject({ layer: "none" });
58
+ });
59
+
60
+ it("is none without a base domain, whatever the mode — no domain, no constructible host", async () => {
61
+ const resolved = await resolveRouting(
62
+ fakeKube({ ingressClasses: 1 }),
63
+ configWith({ mode: "ingress", baseDomain: undefined }),
64
+ );
65
+ expect(resolved).toMatchObject({ layer: "none" });
66
+ });
67
+ });
68
+
69
+ describe("resolveRouting — explicit modes", () => {
70
+ it("takes ingress at its word", async () => {
71
+ const resolved = await resolveRouting(
72
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 0 }),
73
+ configWith({ mode: "ingress" }),
74
+ );
75
+ expect(resolved).toEqual({ layer: "ingress" });
76
+ });
77
+
78
+ it("prefers the GA Gateway API version when several are served", async () => {
79
+ const resolved = await resolveRouting(
80
+ fakeKube({ gatewayVersions: ["v1beta1", "v1"] }),
81
+ configWith({ mode: "gateway", gateway: { name: "gw", namespace: "gw-ns" } }),
82
+ );
83
+ expect(resolved).toEqual({ layer: "gateway", apiVersion: "v1" });
84
+ });
85
+
86
+ it("falls back to v1beta1 where that is all the cluster serves", async () => {
87
+ const resolved = await resolveRouting(
88
+ fakeKube({ gatewayVersions: ["v1beta1"] }),
89
+ configWith({ mode: "gateway", gateway: { name: "gw", namespace: "gw-ns" } }),
90
+ );
91
+ expect(resolved).toEqual({ layer: "gateway", apiVersion: "v1beta1" });
92
+ });
93
+
94
+ it("refuses gateway mode on a cluster that does not serve the API", async () => {
95
+ await expect(
96
+ resolveRouting(
97
+ fakeKube({ ingressClasses: 1 }),
98
+ configWith({ mode: "gateway", gateway: { name: "gw", namespace: "gw-ns" } }),
99
+ ),
100
+ ).rejects.toBeInstanceOf(RunnerConfigError);
101
+ });
102
+ });
103
+
104
+ describe("resolveRouting — auto", () => {
105
+ it("resolves on the CONFIGURED target before the installed one: a named Gateway wins even where Ingress is usable", async () => {
106
+ const resolved = await resolveRouting(
107
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 3 }),
108
+ configWith({ gateway: { name: "gw", namespace: "gw-ns" } }),
109
+ );
110
+ expect(resolved).toEqual({ layer: "gateway", apiVersion: "v1" });
111
+ });
112
+
113
+ it("takes a configured IngressClass even where the Gateway API is installed — CRDs present is not intent", async () => {
114
+ const resolved = await resolveRouting(
115
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 1 }),
116
+ configWith({ ingressClassName: "nginx" }),
117
+ );
118
+ expect(resolved).toEqual({ layer: "ingress" });
119
+ });
120
+
121
+ it("picks the only usable layer when nothing is configured", async () => {
122
+ const resolved = await resolveRouting(fakeKube({ ingressClasses: 1 }), configWith({}));
123
+ expect(resolved).toEqual({ layer: "ingress" });
124
+ });
125
+
126
+ it("refuses when both layers are usable and nothing says which", async () => {
127
+ await expect(
128
+ resolveRouting(fakeKube({ gatewayVersions: ["v1"], ingressClasses: 2 }), configWith({})),
129
+ ).rejects.toThrow(/cannot choose/);
130
+ });
131
+
132
+ it("refuses a Gateway-only cluster with no Gateway named — picking one would fail as silently as publishing none", async () => {
133
+ await expect(
134
+ resolveRouting(fakeKube({ gatewayVersions: ["v1"] }), configWith({})),
135
+ ).rejects.toThrow(/SESSION_GATEWAY_NAME/);
136
+ });
137
+
138
+ it("refuses a cluster with no usable layer at all rather than publishing into the void", async () => {
139
+ await expect(resolveRouting(fakeKube({}), configWith({}))).rejects.toThrow(
140
+ /no usable routing layer/,
141
+ );
142
+ });
143
+
144
+ it("does not read an IngressClass count as usable when the API refused the list", async () => {
145
+ const kube = {
146
+ apis: { getAPIVersions: async () => ({ groups: [] }) },
147
+ networking: {
148
+ listIngressClass: async () => {
149
+ throw new Error("forbidden");
150
+ },
151
+ },
152
+ } as unknown as KubeClient;
153
+ // A permission failure must not be reported as a fact about the cluster:
154
+ // "no IngressClass exists" sends an operator to install a controller they
155
+ // already have.
156
+ await expect(resolveRouting(kube, configWith({}))).rejects.toThrow(
157
+ /listing IngressClasses failed/,
158
+ );
159
+ await expect(resolveRouting(kube, configWith({}))).rejects.not.toThrow(
160
+ /no IngressClass exists/,
161
+ );
162
+ });
163
+
164
+ it("takes a DEFAULT-marked IngressClass as intent, even beside the Gateway API", async () => {
165
+ // The cluster has nominated where an unqualified Ingress goes, and
166
+ // unqualified is what this runner creates. Without this, every cluster
167
+ // shipping the Gateway CRDs beside a default ingress controller would
168
+ // refuse to boot after the upgrade, having worked before it.
169
+ const resolved = await resolveRouting(
170
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 1, defaultIngressClass: true }),
171
+ configWith({}),
172
+ );
173
+ expect(resolved).toEqual({ layer: "ingress" });
174
+ });
175
+
176
+ it("still refuses when several classes exist and none is marked default", async () => {
177
+ await expect(
178
+ resolveRouting(
179
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 2, defaultIngressClass: false }),
180
+ configWith({}),
181
+ ),
182
+ ).rejects.toThrow(/cannot choose/);
183
+ });
184
+
185
+ it("lets a named Gateway win over a default IngressClass — the more specific configuration", async () => {
186
+ const resolved = await resolveRouting(
187
+ fakeKube({ gatewayVersions: ["v1"], ingressClasses: 1, defaultIngressClass: true }),
188
+ configWith({ gateway: { name: "gw", namespace: "gw-ns" } }),
189
+ );
190
+ expect(resolved).toEqual({ layer: "gateway", apiVersion: "v1" });
191
+ });
192
+ });
193
+
194
+ describe("resolveRouting — origin TLS against the RESOLVED layer", () => {
195
+ it("refuses an ingress TLS secret when auto resolves to Gateway API", async () => {
196
+ // The config-time check only sees `mode`, and `auto` is the default: a named
197
+ // Gateway decides the layer later, so this combination booted, routed on
198
+ // Gateway API and ignored the certificate — plaintext origin traffic under a
199
+ // configuration that reads as TLS-configured.
200
+ await expect(
201
+ resolveRouting(
202
+ fakeKube({ gatewayVersions: ["v1"] }),
203
+ configWith({
204
+ gateway: { name: "gw", namespace: "gw-ns" },
205
+ tlsSecretName: "origin-tls",
206
+ }),
207
+ ),
208
+ ).rejects.toThrow(/belongs to the Gateway listener/);
209
+ });
210
+
211
+ it("allows an ingress TLS secret when the resolved layer is ingress", async () => {
212
+ const resolved = await resolveRouting(
213
+ fakeKube({ ingressClasses: 1 }),
214
+ configWith({ ingressClassName: "nginx", tlsSecretName: "origin-tls" }),
215
+ );
216
+ expect(resolved).toEqual({ layer: "ingress" });
217
+ });
218
+ });
@@ -0,0 +1,191 @@
1
+ import type { K8sRunnerConfig } from "../../config.js";
2
+ import { RunnerConfigError } from "../../config.js";
3
+ import { apiReason } from "../api-error.js";
4
+ import type { KubeClient } from "../client.js";
5
+
6
+ const GATEWAY_GROUP = "gateway.networking.k8s.io";
7
+ /** Preference order among served Gateway API versions. `v1` is GA; plenty of
8
+ * clusters still serve only `v1beta1`, and an HTTPRoute posted at a version the
9
+ * apiserver does not serve is a 404 at publish time rather than a bad route. */
10
+ const GATEWAY_VERSIONS = ["v1", "v1beta1"];
11
+
12
+ export type ResolvedRouting =
13
+ | { layer: "none"; reason: string }
14
+ | { layer: "ingress" }
15
+ | { layer: "gateway"; apiVersion: string };
16
+
17
+ /** The annotation by which a cluster nominates the class unqualified Ingresses
18
+ * go to — which is exactly what this runner creates when no class is set. */
19
+ const DEFAULT_CLASS_ANNOTATION = "ingressclass.kubernetes.io/is-default-class";
20
+
21
+ export interface ClusterRoutingSupport {
22
+ /** Served Gateway API version, preferred first; undefined when the group is
23
+ * not served at all. */
24
+ gatewayApiVersion?: string;
25
+ /** Number of IngressClasses, or **undefined when the list could not be read**.
26
+ * `networking.k8s.io` is always served, so the API being present is no
27
+ * evidence anyone can route with it — an installed controller registers a
28
+ * class, and that is the usable signal. Zero and unknown are kept apart
29
+ * because a refusal phrased from them is a different claim: one is about the
30
+ * cluster, the other about this runner's permissions. */
31
+ ingressClassCount?: number;
32
+ /** Set when at least one IngressClass carries the default-class annotation. */
33
+ hasDefaultIngressClass: boolean;
34
+ /** Why the IngressClass list could not be read, when it could not. */
35
+ ingressClassError?: unknown;
36
+ }
37
+
38
+ /** What the cluster can actually route with. Both probes fail SOFT — discovery
39
+ * and IngressClass listing are the runner's least important permissions — but a
40
+ * failure is REPORTED rather than folded into a zero, so nothing downstream can
41
+ * turn a permission error into a claim about the cluster. */
42
+ export async function detectRoutingSupport(kube: KubeClient): Promise<ClusterRoutingSupport> {
43
+ let gatewayApiVersion: string | undefined;
44
+ try {
45
+ const groups = await kube.apis.getAPIVersions();
46
+ const group = groups.groups?.find((g) => g.name === GATEWAY_GROUP);
47
+ const served = new Set((group?.versions ?? []).map((v) => v.version));
48
+ gatewayApiVersion =
49
+ GATEWAY_VERSIONS.find((v) => served.has(v)) ?? group?.preferredVersion?.version;
50
+ } catch {
51
+ gatewayApiVersion = undefined;
52
+ }
53
+
54
+ try {
55
+ const classes = await kube.networking.listIngressClass();
56
+ const items = classes.items ?? [];
57
+ return {
58
+ gatewayApiVersion,
59
+ ingressClassCount: items.length,
60
+ hasDefaultIngressClass: items.some(
61
+ (c) => c.metadata?.annotations?.[DEFAULT_CLASS_ANNOTATION] === "true",
62
+ ),
63
+ };
64
+ } catch (err) {
65
+ return { gatewayApiVersion, hasDefaultIngressClass: false, ingressClassError: err };
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Decide the routing layer ONCE, at boot, so a misconfiguration fails the runner
71
+ * instead of 404-ing every session it later accepts.
72
+ *
73
+ * `auto` resolves on what is CONFIGURED before what is INSTALLED, and that order
74
+ * is the whole point: Gateway API CRDs are frequently present without being the
75
+ * intended path, so "both APIs available" is not evidence of intent and must not
76
+ * decide anything. Only a cluster that offers two usable layers and was told
77
+ * nothing is genuinely ambiguous — and there the answer is to refuse, because
78
+ * guessing wrong publishes routes nothing reconciles, which is silent.
79
+ */
80
+ export async function resolveRouting(
81
+ kube: KubeClient,
82
+ config: K8sRunnerConfig,
83
+ ): Promise<ResolvedRouting> {
84
+ const routing = config.sessionRouting;
85
+
86
+ if (routing.mode === "none") {
87
+ return { layer: "none", reason: "SESSION_ROUTING_MODE=none" };
88
+ }
89
+ // The base domain is what makes a host constructible at all, so it stays the
90
+ // master switch it has always been — no domain, no routing, whatever the mode.
91
+ if (!routing.baseDomain) {
92
+ return {
93
+ layer: "none",
94
+ reason: "SESSION_ROUTING_BASE_DOMAIN is unset, so no session host can be constructed",
95
+ };
96
+ }
97
+
98
+ const support = await detectRoutingSupport(kube);
99
+ const resolved = decideLayer(config, support);
100
+ assertTlsMatchesLayer(config, resolved);
101
+ return resolved;
102
+ }
103
+
104
+ /** The origin certificate is refused HERE as well as at config load, because
105
+ * under `auto` the effective layer is not known until now: a named Gateway wins,
106
+ * so `mode: auto` + a Gateway + `SESSION_INGRESS_TLS_SECRET` passed the
107
+ * config-time check and then routed on Gateway API with the certificate
108
+ * ignored — plaintext origin traffic under a configuration that reads as
109
+ * TLS-configured, which is the silent downgrade the check exists to prevent. */
110
+ function assertTlsMatchesLayer(config: K8sRunnerConfig, resolved: ResolvedRouting): void {
111
+ if (resolved.layer !== "gateway" || !config.sessionRouting.tlsSecretName) return;
112
+ throw new RunnerConfigError(
113
+ "SESSION_INGRESS_TLS_SECRET is set but session routing resolved to Gateway API. Under Gateway " +
114
+ "API the origin certificate belongs to the Gateway listener's own `tls.certificateRefs`, not " +
115
+ "to the per-session route, so this Secret would be silently ignored. Move the reference to " +
116
+ "the Gateway and unset this, or set SESSION_ROUTING_MODE=ingress.",
117
+ );
118
+ }
119
+
120
+ function decideLayer(config: K8sRunnerConfig, support: ClusterRoutingSupport): ResolvedRouting {
121
+ const routing = config.sessionRouting;
122
+
123
+ if (routing.mode === "ingress") return { layer: "ingress" };
124
+
125
+ if (routing.mode === "gateway") {
126
+ if (!support.gatewayApiVersion) {
127
+ throw new RunnerConfigError(
128
+ `SESSION_ROUTING_MODE=gateway but the cluster does not serve '${GATEWAY_GROUP}'. ` +
129
+ "Install the Gateway API CRDs, or set SESSION_ROUTING_MODE=ingress.",
130
+ );
131
+ }
132
+ return { layer: "gateway", apiVersion: support.gatewayApiVersion };
133
+ }
134
+
135
+ // auto — configured target first.
136
+ if (routing.gateway) {
137
+ if (!support.gatewayApiVersion) {
138
+ throw new RunnerConfigError(
139
+ `SESSION_GATEWAY_NAME is set but the cluster does not serve '${GATEWAY_GROUP}'. ` +
140
+ "Install the Gateway API CRDs, or unset SESSION_GATEWAY_NAME to route via Ingress.",
141
+ );
142
+ }
143
+ return { layer: "gateway", apiVersion: support.gatewayApiVersion };
144
+ }
145
+ if (routing.ingressClassName) return { layer: "ingress" };
146
+ // A DEFAULT-marked IngressClass is configuration, not mere availability: the
147
+ // cluster has nominated where an unqualified Ingress goes, and unqualified is
148
+ // exactly what this runner creates. It therefore ranks with a named Gateway
149
+ // and a configured class rather than with "an IngressClass exists" — without
150
+ // which every cluster that ships the Gateway CRDs beside a default ingress
151
+ // controller would refuse to boot after this upgrade, having worked before.
152
+ if (support.hasDefaultIngressClass) return { layer: "ingress" };
153
+
154
+ const gatewayUsable = Boolean(support.gatewayApiVersion);
155
+ const ingressUsable = (support.ingressClassCount ?? 0) > 0;
156
+
157
+ if (gatewayUsable && ingressUsable) {
158
+ throw new RunnerConfigError(
159
+ `SESSION_ROUTING_MODE=auto cannot choose: this cluster serves '${GATEWAY_GROUP}' AND has ` +
160
+ `${support.ingressClassCount} IngressClass(es) with none marked default, and nothing says ` +
161
+ "which should carry session traffic. Set SESSION_ROUTING_MODE=ingress (with " +
162
+ "SESSION_INGRESS_CLASS) or SESSION_ROUTING_MODE=gateway (with SESSION_GATEWAY_NAME).",
163
+ );
164
+ }
165
+ if (gatewayUsable) {
166
+ throw new RunnerConfigError(
167
+ `SESSION_ROUTING_MODE=auto resolved to Gateway API, but SESSION_GATEWAY_NAME is unset — a ` +
168
+ "route has to name the Gateway it attaches to, and picking one of several would fail as " +
169
+ "silently as publishing none. Set SESSION_GATEWAY_NAME (and SESSION_GATEWAY_NAMESPACE " +
170
+ `when the Gateway is not in '${config.sessionNamespace}').`,
171
+ );
172
+ }
173
+ if (ingressUsable) return { layer: "ingress" };
174
+
175
+ // Say which of the two it is. Reporting a permission failure as "no
176
+ // IngressClass exists" is a claim about the cluster drawn from a fact about
177
+ // this runner's access, and it sends an operator to install a controller they
178
+ // already have.
179
+ if (support.ingressClassError !== undefined) {
180
+ throw new RunnerConfigError(
181
+ "SESSION_ROUTING_BASE_DOMAIN is set but the routing layer could not be resolved: listing " +
182
+ `IngressClasses failed (${apiReason(support.ingressClassError)}) and '${GATEWAY_GROUP}' is ` +
183
+ "not served. Grant `list` on `ingressclasses`, or set SESSION_ROUTING_MODE explicitly.",
184
+ );
185
+ }
186
+ throw new RunnerConfigError(
187
+ "SESSION_ROUTING_BASE_DOMAIN is set but this cluster offers no usable routing layer: no " +
188
+ `IngressClass exists and '${GATEWAY_GROUP}' is not served. Install an Ingress controller or ` +
189
+ "the Gateway API, or set SESSION_ROUTING_MODE=none to run logs-only.",
190
+ );
191
+ }
@@ -0,0 +1,45 @@
1
+ import type { PortMapping, RunnerEndpoint } from "@telorun/runner-core";
2
+
3
+ import type { K8sRunnerConfig } from "../../config.js";
4
+
5
+ /** Host fronting a single tcp port: `<port>-<sessionId>.<domain>`. The port rides
6
+ * as a leading label (no dots), so it stays a single label under the base domain
7
+ * — matching the docker runner's proxy scheme and compatible with a single-label
8
+ * wildcard cert (`*.<domain>`).
9
+ *
10
+ * Shared by both routing layers on purpose: the host scheme is what a client is
11
+ * told and what DNS is configured for, so it must not vary with the cluster's
12
+ * choice of Ingress or Gateway API. */
13
+ export function hostForPort(config: K8sRunnerConfig, sessionId: string, port: number): string {
14
+ return `${port}-${sessionId}.${config.sessionRouting.baseDomain}`;
15
+ }
16
+
17
+ /**
18
+ * Endpoints announced on the `running` status. Every tcp port is fronted by its
19
+ * own per-session host (served on 443) and carries an external `url`. udp ports
20
+ * aren't HTTP-routable, so they keep the host-less form. Without a base domain,
21
+ * host is left blank for the client adapter to fill (parity with docker).
22
+ *
23
+ * `routed` is what says a URL will be served, and it is separate from the base
24
+ * domain because the two can disagree: `SESSION_ROUTING_MODE=none` with a domain
25
+ * configured is a valid, documented setup that publishes nothing. Keying on the
26
+ * domain alone handed the editor a URL nothing serves — and the route watch
27
+ * never runs in that mode, so no `route` event would have said so either.
28
+ */
29
+ export function endpointsFor(
30
+ config: K8sRunnerConfig,
31
+ sessionId: string,
32
+ ports: PortMapping[],
33
+ routed: boolean,
34
+ ): RunnerEndpoint[] {
35
+ if (!routed || !config.sessionRouting.baseDomain || ports.length === 0) {
36
+ return ports.map((p) => ({ host: "", port: p.port, protocol: p.protocol }));
37
+ }
38
+ return ports.map((p) => {
39
+ if (p.protocol !== "tcp") {
40
+ return { host: "", port: p.port, protocol: p.protocol };
41
+ }
42
+ const host = hostForPort(config, sessionId, p.port);
43
+ return { host, port: p.port, protocol: p.protocol, url: `https://${host}` };
44
+ });
45
+ }
@@ -0,0 +1,59 @@
1
+ import type { PortMapping } from "@telorun/runner-core";
2
+
3
+ /** One published host and the session port behind it. */
4
+ export interface PublishedRoute {
5
+ host: string;
6
+ port: number;
7
+ }
8
+
9
+ export interface PublishRouteArgs {
10
+ sessionId: string;
11
+ /** The Service the routing object points at. */
12
+ serviceName: string;
13
+ /** Owner of every object created — see `podOwnerRef`. */
14
+ podName: string;
15
+ podUid: string;
16
+ /** Every port the session must carry. Non-tcp ports are ignored: they are not
17
+ * HTTP-routable, and the Service still exposes them. */
18
+ ports: PortMapping[];
19
+ }
20
+
21
+ /**
22
+ * What a routing layer has to say about one published host.
23
+ *
24
+ * `pending` is a real answer, not an absence: a controller that has not yet
25
+ * written status is indistinguishable from one that will never claim the route,
26
+ * and only time separates them.
27
+ */
28
+ export type RouteVerdict =
29
+ | { kind: "programmed" }
30
+ | { kind: "pending" }
31
+ | { kind: "rejected"; reason: string };
32
+
33
+ /**
34
+ * How this cluster publishes a session's public routes.
35
+ *
36
+ * The seam exists because Ingress and Gateway API are both current: neither is a
37
+ * migration target for the other, clusters ship one, the other, or both, and a
38
+ * runner that hardcodes either is unroutable on half of them. It deliberately
39
+ * stays inside the k8s backend — docker publishes host ports through its own
40
+ * proxy and has no use for it, and `runner-core` owns the backend-neutral `/v1`
41
+ * contract, where "Ingress or HTTPRoute" is not a distinction that exists.
42
+ */
43
+ export interface SessionRouter {
44
+ /** Names the layer for diagnostics. */
45
+ readonly layer: "ingress" | "gateway";
46
+
47
+ /** Create-or-replace this session's routing objects. Returns the hosts it
48
+ * published, which is what the route watch then asks about. */
49
+ publish(args: PublishRouteArgs): Promise<PublishedRoute[]>;
50
+
51
+ /** This layer's current verdict on one published host. Asked repeatedly by the
52
+ * route watch until it settles or the deadline passes. */
53
+ verdictFor(sessionId: string, route: PublishedRoute): Promise<RouteVerdict>;
54
+
55
+ /** What to tell an operator when a route is still `pending` at the deadline.
56
+ * Layer-specific because the causes are: no controller for the IngressClass,
57
+ * versus a Gateway that never claimed the route. */
58
+ unclaimedReason(): string;
59
+ }
@@ -0,0 +1,55 @@
1
+ import type { V1OwnerReference, V1Service } from "@kubernetes/client-node";
2
+
3
+ import type { PortMapping } from "@telorun/runner-core";
4
+
5
+ import type { K8sRunnerConfig } from "../../config.js";
6
+
7
+ /** OwnerReference to the session Pod so every routing object is garbage collected
8
+ * when the Pod dies — essential for sub-minute sessions that would otherwise leak
9
+ * routing objects. Both routing layers use it: an HTTPRoute is namespaced like an
10
+ * Ingress, so the same ownership works unchanged. */
11
+ export function podOwnerRef(podName: string, podUid: string): V1OwnerReference {
12
+ return {
13
+ apiVersion: "v1",
14
+ kind: "Pod",
15
+ name: podName,
16
+ uid: podUid,
17
+ controller: true,
18
+ blockOwnerDeletion: true,
19
+ };
20
+ }
21
+
22
+ /** The name every routing object for one session shares. */
23
+ export function sessionObjectName(sessionId: string): string {
24
+ return `telo-run-${sessionId}`;
25
+ }
26
+
27
+ /** The Service both routing layers point at — the routing layer decides how
28
+ * traffic ARRIVES, never what it arrives at, so this is layer-neutral. */
29
+ export function buildSessionService(
30
+ config: K8sRunnerConfig,
31
+ sessionId: string,
32
+ podName: string,
33
+ podUid: string,
34
+ ports: PortMapping[],
35
+ ): V1Service {
36
+ return {
37
+ apiVersion: "v1",
38
+ kind: "Service",
39
+ metadata: {
40
+ name: sessionObjectName(sessionId),
41
+ namespace: config.sessionNamespace,
42
+ labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
43
+ ownerReferences: [podOwnerRef(podName, podUid)],
44
+ },
45
+ spec: {
46
+ selector: { "telo.run/session-id": sessionId },
47
+ ports: ports.map((p) => ({
48
+ name: `p${p.port}`,
49
+ port: p.port,
50
+ targetPort: p.port,
51
+ protocol: p.protocol.toUpperCase(),
52
+ })),
53
+ },
54
+ };
55
+ }
@@ -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