@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,236 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { PortMapping } from "@telorun/runner-core";
4
+
5
+ import type { K8sRunnerConfig } from "../../config.js";
6
+ import type { KubeClient } from "../client.js";
7
+ import { buildSessionIngress, createIngressRouter } from "./ingress-router.js";
8
+ import { endpointsFor } from "./session-endpoints.js";
9
+
10
+ const config = {
11
+ sessionNamespace: "telo-sessions",
12
+ sessionRouting: {
13
+ mode: "ingress",
14
+ baseDomain: "telo.run",
15
+ ingressClassName: "nginx",
16
+ routeReadyTimeoutMs: 60_000,
17
+ },
18
+ managedByLabel: "telo-k8s-runner",
19
+ } as K8sRunnerConfig;
20
+
21
+ const sessionId = "hzyayvabgyvz";
22
+ const ports: PortMapping[] = [
23
+ { port: 8080, protocol: "tcp" },
24
+ { port: 9090, protocol: "tcp" },
25
+ { port: 5000, protocol: "udp" },
26
+ ];
27
+
28
+ function withRouting(patch: Partial<K8sRunnerConfig["sessionRouting"]>): K8sRunnerConfig {
29
+ return { ...config, sessionRouting: { ...config.sessionRouting, ...patch } } as K8sRunnerConfig;
30
+ }
31
+
32
+ describe("endpointsFor", () => {
33
+ it("gives every tcp port a <port>-<id>.<domain> host + url, udp host-less", () => {
34
+ expect(endpointsFor(config, sessionId, ports, true)).toEqual([
35
+ {
36
+ host: `8080-${sessionId}.telo.run`,
37
+ port: 8080,
38
+ protocol: "tcp",
39
+ url: `https://8080-${sessionId}.telo.run`,
40
+ },
41
+ {
42
+ host: `9090-${sessionId}.telo.run`,
43
+ port: 9090,
44
+ protocol: "tcp",
45
+ url: `https://9090-${sessionId}.telo.run`,
46
+ },
47
+ { host: "", port: 5000, protocol: "udp" },
48
+ ]);
49
+ });
50
+
51
+ it("leaves hosts blank when no routing base domain is configured", () => {
52
+ expect(endpointsFor(withRouting({ baseDomain: undefined }), sessionId, ports, true)).toEqual([
53
+ { host: "", port: 8080, protocol: "tcp" },
54
+ { host: "", port: 9090, protocol: "tcp" },
55
+ { host: "", port: 5000, protocol: "udp" },
56
+ ]);
57
+ });
58
+
59
+ it("advertises no url when nothing publishes routes, even with a base domain set", () => {
60
+ // `SESSION_ROUTING_MODE=none` with a domain configured is documented and
61
+ // valid. Keying on the domain alone handed the editor a URL nothing serves,
62
+ // and the route watch never runs in that mode — so no event said so either.
63
+ expect(endpointsFor(withRouting({ mode: "none" }), sessionId, ports, false)).toEqual([
64
+ { host: "", port: 8080, protocol: "tcp" },
65
+ { host: "", port: 9090, protocol: "tcp" },
66
+ { host: "", port: 5000, protocol: "udp" },
67
+ ]);
68
+ });
69
+
70
+ it("keeps the host scheme identical whatever layer routes it", () => {
71
+ const viaGateway = withRouting({ mode: "gateway", gateway: { name: "gw", namespace: "gw-ns" } });
72
+ expect(endpointsFor(viaGateway, sessionId, ports, true)).toEqual(
73
+ endpointsFor(config, sessionId, ports, true),
74
+ );
75
+ });
76
+ });
77
+
78
+ describe("buildSessionIngress", () => {
79
+ it("emits one rule per tcp port to the matching service port", () => {
80
+ const { ingress, hosts } = buildSessionIngress(
81
+ config,
82
+ sessionId,
83
+ `telo-run-${sessionId}`,
84
+ "pod",
85
+ "uid",
86
+ ports,
87
+ );
88
+ expect(hosts).toEqual([`8080-${sessionId}.telo.run`, `9090-${sessionId}.telo.run`]);
89
+ expect(ingress.spec?.ingressClassName).toBe("nginx");
90
+ expect(ingress.spec?.rules).toEqual([
91
+ {
92
+ host: `8080-${sessionId}.telo.run`,
93
+ http: {
94
+ paths: [
95
+ {
96
+ path: "/",
97
+ pathType: "Prefix",
98
+ backend: { service: { name: `telo-run-${sessionId}`, port: { number: 8080 } } },
99
+ },
100
+ ],
101
+ },
102
+ },
103
+ {
104
+ host: `9090-${sessionId}.telo.run`,
105
+ http: {
106
+ paths: [
107
+ {
108
+ path: "/",
109
+ pathType: "Prefix",
110
+ backend: { service: { name: `telo-run-${sessionId}`, port: { number: 9090 } } },
111
+ },
112
+ ],
113
+ },
114
+ },
115
+ ]);
116
+ });
117
+
118
+ it("produces no rules when there are no tcp ports", () => {
119
+ const { ingress, hosts } = buildSessionIngress(
120
+ config,
121
+ sessionId,
122
+ `telo-run-${sessionId}`,
123
+ "pod",
124
+ "uid",
125
+ [{ port: 5000, protocol: "udp" }],
126
+ );
127
+ expect(hosts).toEqual([]);
128
+ expect(ingress.spec?.rules).toEqual([]);
129
+ });
130
+
131
+ it("omits the tls block when no tls secret is configured", () => {
132
+ const { ingress } = buildSessionIngress(
133
+ config,
134
+ sessionId,
135
+ `telo-run-${sessionId}`,
136
+ "pod",
137
+ "uid",
138
+ ports,
139
+ );
140
+ expect(ingress.spec?.tls).toBeUndefined();
141
+ });
142
+
143
+ it("presents the configured tls secret for every tcp host", () => {
144
+ const { ingress } = buildSessionIngress(
145
+ withRouting({ tlsSecretName: "telo-origin-tls" }),
146
+ sessionId,
147
+ `telo-run-${sessionId}`,
148
+ "pod",
149
+ "uid",
150
+ ports,
151
+ );
152
+ expect(ingress.spec?.tls).toEqual([
153
+ {
154
+ hosts: [`8080-${sessionId}.telo.run`, `9090-${sessionId}.telo.run`],
155
+ secretName: "telo-origin-tls",
156
+ },
157
+ ]);
158
+ });
159
+
160
+ it("omits the tls block when there are no tcp hosts to secure", () => {
161
+ const { ingress } = buildSessionIngress(
162
+ withRouting({ tlsSecretName: "telo-origin-tls" }),
163
+ sessionId,
164
+ `telo-run-${sessionId}`,
165
+ "pod",
166
+ "uid",
167
+ [{ port: 5000, protocol: "udp" }],
168
+ );
169
+ expect(ingress.spec?.tls).toBeUndefined();
170
+ });
171
+
172
+ it("carries the session label and the pod ownerReference every routing object does", () => {
173
+ const { ingress } = buildSessionIngress(
174
+ config,
175
+ sessionId,
176
+ `telo-run-${sessionId}`,
177
+ "pod",
178
+ "uid",
179
+ ports,
180
+ );
181
+ expect(ingress.metadata?.labels?.["telo.run/session-id"]).toBe(sessionId);
182
+ expect(ingress.metadata?.ownerReferences?.[0]).toMatchObject({ kind: "Pod", name: "pod" });
183
+ });
184
+ });
185
+
186
+ describe("createIngressRouter.publish", () => {
187
+ function fakeKube() {
188
+ const rec = { created: 0, deleted: [] as string[] };
189
+ const kube = {
190
+ networking: {
191
+ createNamespacedIngress: async () => {
192
+ rec.created += 1;
193
+ return {};
194
+ },
195
+ deleteNamespacedIngress: async ({ name }: { name: string }) => {
196
+ rec.deleted.push(name);
197
+ return {};
198
+ },
199
+ },
200
+ } as unknown as KubeClient;
201
+ return { kube, rec };
202
+ }
203
+
204
+ const args = {
205
+ sessionId,
206
+ serviceName: `telo-run-${sessionId}`,
207
+ podName: "pod",
208
+ podUid: "uid",
209
+ };
210
+
211
+ it("deletes the session Ingress when a reload leaves no routable port", async () => {
212
+ // Returning early left the previous Ingress serving a port nothing listens
213
+ // on — the drop-all case the per-port gateway cleanup already handled.
214
+ const { kube, rec } = fakeKube();
215
+ const routes = await createIngressRouter(kube, config).publish({
216
+ ...args,
217
+ ports: [{ port: 5000, protocol: "udp" }],
218
+ });
219
+ expect(routes).toEqual([]);
220
+ expect(rec.created).toBe(0);
221
+ expect(rec.deleted).toEqual([`telo-run-${sessionId}`]);
222
+ });
223
+
224
+ it("treats an already-absent Ingress as nothing to delete", async () => {
225
+ const kube = {
226
+ networking: {
227
+ deleteNamespacedIngress: async () => {
228
+ throw Object.assign(new Error("not found"), { code: 404 });
229
+ },
230
+ },
231
+ } as unknown as KubeClient;
232
+ await expect(
233
+ createIngressRouter(kube, config).publish({ ...args, ports: [] }),
234
+ ).resolves.toEqual([]);
235
+ });
236
+ });
@@ -0,0 +1,157 @@
1
+ import type { V1Ingress } from "@kubernetes/client-node";
2
+
3
+ import type { PortMapping } from "@telorun/runner-core";
4
+
5
+ import type { K8sRunnerConfig } from "../../config.js";
6
+ import type { KubeClient } from "../client.js";
7
+ import { is404 } from "../pod-status.js";
8
+ import { createOrReplace } from "./create-or-replace.js";
9
+ import { hostForPort } from "./session-endpoints.js";
10
+ import { podOwnerRef, sessionObjectName } from "./session-service.js";
11
+ import type {
12
+ PublishedRoute,
13
+ PublishRouteArgs,
14
+ RouteVerdict,
15
+ SessionRouter,
16
+ } from "./session-router.js";
17
+
18
+ /** One Ingress carries every host for a session: unlike Gateway API, an Ingress
19
+ * rule is scoped to its own host, so per-host backends fit in one object. */
20
+ export function buildSessionIngress(
21
+ config: K8sRunnerConfig,
22
+ sessionId: string,
23
+ serviceName: string,
24
+ podName: string,
25
+ podUid: string,
26
+ ports: PortMapping[],
27
+ ): { ingress: V1Ingress; hosts: string[] } {
28
+ const rules = ports
29
+ .filter((p) => p.protocol === "tcp")
30
+ .map((p) => ({
31
+ host: hostForPort(config, sessionId, p.port),
32
+ http: {
33
+ paths: [
34
+ {
35
+ path: "/",
36
+ pathType: "Prefix" as const,
37
+ backend: { service: { name: serviceName, port: { number: p.port } } },
38
+ },
39
+ ],
40
+ },
41
+ }));
42
+ const ingress: V1Ingress = {
43
+ apiVersion: "networking.k8s.io/v1",
44
+ kind: "Ingress",
45
+ metadata: {
46
+ name: sessionObjectName(sessionId),
47
+ namespace: config.sessionNamespace,
48
+ labels: {
49
+ "app.kubernetes.io/managed-by": config.managedByLabel,
50
+ "telo.run/session-id": sessionId,
51
+ },
52
+ ownerReferences: [podOwnerRef(podName, podUid)],
53
+ },
54
+ spec: {
55
+ ...(config.sessionRouting.ingressClassName
56
+ ? { ingressClassName: config.sessionRouting.ingressClassName }
57
+ : {}),
58
+ // Present the predefined cert (e.g. a Cloudflare Origin cert) so an upstream
59
+ // in Full (Strict) mode can validate the origin. Only meaningful with routable
60
+ // hosts; a single wildcard `*.<domain>` Secret covers every session host.
61
+ ...(config.sessionRouting.tlsSecretName && rules.length > 0
62
+ ? {
63
+ tls: [
64
+ {
65
+ hosts: rules.map((r) => r.host),
66
+ secretName: config.sessionRouting.tlsSecretName,
67
+ },
68
+ ],
69
+ }
70
+ : {}),
71
+ rules,
72
+ },
73
+ };
74
+ return { ingress, hosts: rules.map((r) => r.host) };
75
+ }
76
+
77
+ export function createIngressRouter(kube: KubeClient, config: K8sRunnerConfig): SessionRouter {
78
+ const ns = config.sessionNamespace;
79
+
80
+ return {
81
+ layer: "ingress",
82
+
83
+ async publish(args: PublishRouteArgs): Promise<PublishedRoute[]> {
84
+ const { ingress } = buildSessionIngress(
85
+ config,
86
+ args.sessionId,
87
+ args.serviceName,
88
+ args.podName,
89
+ args.podUid,
90
+ args.ports,
91
+ );
92
+ const name = ingress.metadata!.name!;
93
+ const rules = ingress.spec?.rules ?? [];
94
+ // No tcp ports → nothing HTTP-routable. DELETE rather than return: a reload
95
+ // that drops the last routed port would otherwise leave the previous
96
+ // Ingress serving a port nothing listens on. (The Service stays for any udp.)
97
+ if (rules.length === 0) {
98
+ try {
99
+ await kube.networking.deleteNamespacedIngress({ name, namespace: ns });
100
+ } catch (err) {
101
+ if (!is404(err)) throw err;
102
+ }
103
+ return [];
104
+ }
105
+ await createOrReplace(
106
+ () => kube.networking.createNamespacedIngress({ namespace: ns, body: ingress }),
107
+ async () => {
108
+ const existing = await kube.networking.readNamespacedIngress({ name, namespace: ns });
109
+ await kube.networking.replaceNamespacedIngress({
110
+ name,
111
+ namespace: ns,
112
+ body: {
113
+ ...ingress,
114
+ metadata: {
115
+ ...ingress.metadata,
116
+ resourceVersion: existing.metadata?.resourceVersion,
117
+ },
118
+ },
119
+ });
120
+ },
121
+ );
122
+ return args.ports
123
+ .filter((p) => p.protocol === "tcp")
124
+ .map((p) => ({ host: hostForPort(config, args.sessionId, p.port), port: p.port }));
125
+ },
126
+
127
+ /**
128
+ * Ingress offers no rejection signal — a controller that refuses a rule simply
129
+ * never writes status — so this only ever answers `programmed` or `pending`,
130
+ * and an unclaimed Ingress is caught by the deadline rather than by a reason
131
+ * the cluster gave. That is the weaker half of the two layers, and it is a
132
+ * property of the API, not of this implementation.
133
+ */
134
+ async verdictFor(sessionId: string): Promise<RouteVerdict> {
135
+ try {
136
+ const ingress = await kube.networking.readNamespacedIngress({
137
+ name: sessionObjectName(sessionId),
138
+ namespace: ns,
139
+ });
140
+ const assigned = ingress.status?.loadBalancer?.ingress ?? [];
141
+ return assigned.length > 0 ? { kind: "programmed" } : { kind: "pending" };
142
+ } catch (err) {
143
+ if (is404(err)) return { kind: "pending" };
144
+ throw err;
145
+ }
146
+ },
147
+
148
+ unclaimedReason(): string {
149
+ const cls = config.sessionRouting.ingressClassName;
150
+ return (
151
+ `no ingress controller claimed the session Ingress` +
152
+ (cls ? ` for class '${cls}'` : " (no ingressClassName configured)") +
153
+ `. Check that an Ingress controller is installed and watching namespace '${ns}'.`
154
+ );
155
+ },
156
+ };
157
+ }
@@ -0,0 +1,215 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { RouteState } from "@telorun/runner-core";
4
+
5
+ import { watchRouteHealth } from "./route-health.js";
6
+ import type { PublishedRoute, RouteVerdict, SessionRouter } from "./session-router.js";
7
+
8
+ const routes: PublishedRoute[] = [
9
+ { host: "8080-abc.telo.run", port: 8080 },
10
+ { host: "9090-abc.telo.run", port: 9090 },
11
+ ];
12
+
13
+ function router(verdicts: (route: PublishedRoute) => RouteVerdict): SessionRouter {
14
+ return {
15
+ layer: "gateway",
16
+ publish: async () => routes,
17
+ verdictFor: async (_id, route) => verdicts(route),
18
+ unclaimedReason: () => "nobody claimed it",
19
+ };
20
+ }
21
+
22
+ function collect() {
23
+ const seen: Array<{ host: string; state: RouteState; reason?: string }> = [];
24
+ return {
25
+ seen,
26
+ onState: (route: PublishedRoute, state: RouteState, reason?: string) =>
27
+ seen.push({ host: route.host, state, ...(reason ? { reason } : {}) }),
28
+ };
29
+ }
30
+
31
+ describe("watchRouteHealth", () => {
32
+ it("reports pending up front, then programmed per host", async () => {
33
+ const { seen, onState } = collect();
34
+ await watchRouteHealth({
35
+ router: router(() => ({ kind: "programmed" })),
36
+ sessionId: "abc",
37
+ routes,
38
+ onState,
39
+ signal: new AbortController().signal,
40
+ timeoutMs: 1_000,
41
+ intervalMs: 1,
42
+ });
43
+ expect(seen).toEqual([
44
+ { host: "8080-abc.telo.run", state: "pending" },
45
+ { host: "9090-abc.telo.run", state: "pending" },
46
+ { host: "8080-abc.telo.run", state: "programmed" },
47
+ { host: "9090-abc.telo.run", state: "programmed" },
48
+ ]);
49
+ });
50
+
51
+ it("carries a refusal's reason straight through", async () => {
52
+ const { seen, onState } = collect();
53
+ await watchRouteHealth({
54
+ router: router(() => ({ kind: "rejected", reason: "NotAllowedByListeners" })),
55
+ sessionId: "abc",
56
+ routes: [routes[0]],
57
+ onState,
58
+ signal: new AbortController().signal,
59
+ timeoutMs: 1_000,
60
+ intervalMs: 1,
61
+ });
62
+ expect(seen.at(-1)).toEqual({
63
+ host: "8080-abc.telo.run",
64
+ state: "unprogrammed",
65
+ reason: "NotAllowedByListeners",
66
+ });
67
+ });
68
+
69
+ it("calls a route that stays pending unprogrammed at the deadline — the silent-404 case", async () => {
70
+ const { seen, onState } = collect();
71
+ await watchRouteHealth({
72
+ router: router(() => ({ kind: "pending" })),
73
+ sessionId: "abc",
74
+ routes: [routes[0]],
75
+ onState,
76
+ signal: new AbortController().signal,
77
+ timeoutMs: 5,
78
+ intervalMs: 1,
79
+ });
80
+ expect(seen.at(-1)).toEqual({
81
+ host: "8080-abc.telo.run",
82
+ state: "unprogrammed",
83
+ reason: "nobody claimed it",
84
+ });
85
+ });
86
+
87
+ it("settles each host independently", async () => {
88
+ const { seen, onState } = collect();
89
+ await watchRouteHealth({
90
+ router: router((route) =>
91
+ route.port === 8080 ? { kind: "programmed" } : { kind: "rejected", reason: "no listener" },
92
+ ),
93
+ sessionId: "abc",
94
+ routes,
95
+ onState,
96
+ signal: new AbortController().signal,
97
+ timeoutMs: 1_000,
98
+ intervalMs: 1,
99
+ });
100
+ expect(seen.filter((s) => s.state !== "pending")).toEqual([
101
+ { host: "8080-abc.telo.run", state: "programmed" },
102
+ { host: "9090-abc.telo.run", state: "unprogrammed", reason: "no listener" },
103
+ ]);
104
+ });
105
+
106
+ it("keeps polling through a transient API error instead of declaring the route dead", async () => {
107
+ let calls = 0;
108
+ const flaky: SessionRouter = {
109
+ layer: "ingress",
110
+ publish: async () => routes,
111
+ verdictFor: async () => {
112
+ calls += 1;
113
+ if (calls < 3) throw new Error("apiserver hiccup");
114
+ return { kind: "programmed" };
115
+ },
116
+ unclaimedReason: () => "nobody claimed it",
117
+ };
118
+ const { seen, onState } = collect();
119
+ await watchRouteHealth({
120
+ router: flaky,
121
+ sessionId: "abc",
122
+ routes: [routes[0]],
123
+ onState,
124
+ signal: new AbortController().signal,
125
+ timeoutMs: 1_000,
126
+ intervalMs: 1,
127
+ });
128
+ expect(seen.at(-1)).toEqual({ host: "8080-abc.telo.run", state: "programmed" });
129
+ });
130
+
131
+ it("reports the READ failure at the deadline, not the Gateway advice", async () => {
132
+ // A permanent read failure (no RBAC, an unserved API version) polls out the
133
+ // whole deadline. Reporting `unclaimedReason()` there sends an operator to
134
+ // inspect the Gateway when the runner could not read its own object.
135
+ const denied: SessionRouter = {
136
+ layer: "gateway",
137
+ publish: async () => routes,
138
+ verdictFor: async () => {
139
+ throw Object.assign(new Error("httproutes is forbidden"), { code: 403 });
140
+ },
141
+ unclaimedReason: () => "no controller claimed the session HTTPRoute",
142
+ };
143
+ const seenErrors: unknown[] = [];
144
+ const { seen, onState } = collect();
145
+ await watchRouteHealth({
146
+ router: denied,
147
+ sessionId: "abc",
148
+ routes: [routes[0]],
149
+ onState,
150
+ onReadError: (err) => seenErrors.push(err),
151
+ signal: new AbortController().signal,
152
+ timeoutMs: 5,
153
+ intervalMs: 1,
154
+ });
155
+ expect(seen.at(-1)?.state).toBe("unprogrammed");
156
+ expect(seen.at(-1)?.reason).toMatch(/route status could not be read/);
157
+ expect(seen.at(-1)?.reason).not.toMatch(/claimed the session HTTPRoute/);
158
+ // Reported once, not once per poll.
159
+ expect(seenErrors).toHaveLength(1);
160
+ });
161
+
162
+ it("does not blame a transient read failure once a verdict was obtained", async () => {
163
+ let calls = 0;
164
+ const flaky: SessionRouter = {
165
+ layer: "gateway",
166
+ publish: async () => routes,
167
+ verdictFor: async () => {
168
+ calls += 1;
169
+ if (calls === 1) throw new Error("apiserver hiccup");
170
+ return { kind: "pending" };
171
+ },
172
+ unclaimedReason: () => "nobody claimed it",
173
+ };
174
+ const { seen, onState } = collect();
175
+ await watchRouteHealth({
176
+ router: flaky,
177
+ sessionId: "abc",
178
+ routes: [routes[0]],
179
+ onState,
180
+ signal: new AbortController().signal,
181
+ timeoutMs: 8,
182
+ intervalMs: 1,
183
+ });
184
+ expect(seen.at(-1)?.reason).toBe("nobody claimed it");
185
+ });
186
+
187
+ it("stops on abort without reporting a verdict it never reached", async () => {
188
+ const abort = new AbortController();
189
+ const { seen, onState } = collect();
190
+ abort.abort();
191
+ await watchRouteHealth({
192
+ router: router(() => ({ kind: "pending" })),
193
+ sessionId: "abc",
194
+ routes,
195
+ onState,
196
+ signal: abort.signal,
197
+ timeoutMs: 1_000,
198
+ intervalMs: 1,
199
+ });
200
+ expect(seen.every((s) => s.state === "pending")).toBe(true);
201
+ });
202
+
203
+ it("does nothing when nothing was published", async () => {
204
+ const { seen, onState } = collect();
205
+ await watchRouteHealth({
206
+ router: router(() => ({ kind: "programmed" })),
207
+ sessionId: "abc",
208
+ routes: [],
209
+ onState,
210
+ signal: new AbortController().signal,
211
+ timeoutMs: 1_000,
212
+ });
213
+ expect(seen).toEqual([]);
214
+ });
215
+ });
@@ -0,0 +1,95 @@
1
+ import { abortableDelay, type RouteState } from "@telorun/runner-core";
2
+
3
+ import { apiReason } from "../api-error.js";
4
+ import type { PublishedRoute, SessionRouter } from "./session-router.js";
5
+
6
+ export interface WatchRouteHealthOptions {
7
+ router: SessionRouter;
8
+ sessionId: string;
9
+ /** The hosts `publish` reported. Empty → nothing to watch. */
10
+ routes: PublishedRoute[];
11
+ /** Receives every transition for one host. */
12
+ onState: (route: PublishedRoute, state: RouteState, reason?: string) => void;
13
+ /** Aborts the watch — wire to the session's teardown. */
14
+ signal: AbortSignal;
15
+ /** How long a route may stay unclaimed before it is reported `unprogrammed`. */
16
+ timeoutMs: number;
17
+ /** Poll interval while waiting for a verdict. */
18
+ intervalMs?: number;
19
+ /** Receives a read failure once, so a permanent one (a missing RBAC grant, an
20
+ * unserved API version) reaches the runner's log and not only the client. */
21
+ onReadError?: (err: unknown) => void;
22
+ }
23
+
24
+ const DEFAULT_INTERVAL_MS = 2_000;
25
+
26
+ /**
27
+ * Watch whether the routing layer actually programmed each published host.
28
+ *
29
+ * This is the check whose absence made an unroutable cluster indistinguishable
30
+ * from a healthy one: the runner already dials the workload's own address, which
31
+ * proves the app is listening and says nothing about whether traffic can reach
32
+ * it. A session whose routing objects are reconciled by no controller reported
33
+ * every port reachable while every public URL 404'd.
34
+ *
35
+ * REPORTS, never fails the session: a slow controller and an absent one look the
36
+ * same for the first few seconds, and killing a healthy session over routing that
37
+ * arrives late would trade a visible problem for a worse one.
38
+ */
39
+ export async function watchRouteHealth(options: WatchRouteHealthOptions): Promise<void> {
40
+ const { router, sessionId, routes, onState, signal } = options;
41
+ if (routes.length === 0) return;
42
+ const interval = options.intervalMs ?? DEFAULT_INTERVAL_MS;
43
+ const deadline = Date.now() + options.timeoutMs;
44
+
45
+ const pending = new Map(routes.map((r) => [r.host, r]));
46
+ for (const route of routes) onState(route, "pending");
47
+
48
+ // A read failure is NOT a verdict, but it is also not nothing: a permanent one
49
+ // (no RBAC on the routing objects, an API version this runner did not resolve)
50
+ // polls out the whole deadline and would otherwise be reported as
51
+ // `unclaimedReason()` — sending an operator to inspect the Gateway when the
52
+ // runner could not read its own object. So the last one is retained and wins
53
+ // at the deadline.
54
+ let lastReadError: unknown;
55
+ let reportedReadError = false;
56
+
57
+ while (pending.size > 0 && !signal.aborted) {
58
+ for (const [host, route] of [...pending]) {
59
+ if (signal.aborted) return;
60
+ let verdict;
61
+ try {
62
+ verdict = await router.verdictFor(sessionId, route);
63
+ } catch (err) {
64
+ lastReadError = err;
65
+ if (!reportedReadError) {
66
+ reportedReadError = true;
67
+ options.onReadError?.(err);
68
+ }
69
+ continue;
70
+ }
71
+ // A verdict of ANY kind proves the read path works, so an earlier failure
72
+ // was transient and must not decide the message at the deadline.
73
+ lastReadError = undefined;
74
+ if (verdict.kind === "programmed") {
75
+ pending.delete(host);
76
+ onState(route, "programmed");
77
+ } else if (verdict.kind === "rejected") {
78
+ pending.delete(host);
79
+ onState(route, "unprogrammed", verdict.reason);
80
+ }
81
+ }
82
+ if (pending.size === 0) return;
83
+ if (Date.now() >= deadline) break;
84
+ await abortableDelay(interval, signal);
85
+ }
86
+
87
+ if (signal.aborted) return;
88
+ const reason =
89
+ lastReadError !== undefined
90
+ ? `route status could not be read: ${apiReason(lastReadError)}`
91
+ : router.unclaimedReason();
92
+ for (const route of pending.values()) {
93
+ onState(route, "unprogrammed", reason);
94
+ }
95
+ }