@telorun/k8s-runner 0.14.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 (90) hide show
  1. package/README.md +93 -14
  2. package/dist/config.d.ts +44 -9
  3. package/dist/config.d.ts.map +1 -1
  4. package/dist/config.js +51 -4
  5. package/dist/config.js.map +1 -1
  6. package/dist/k8s/backend.d.ts +4 -0
  7. package/dist/k8s/backend.d.ts.map +1 -1
  8. package/dist/k8s/backend.js +55 -17
  9. package/dist/k8s/backend.js.map +1 -1
  10. package/dist/k8s/client.d.ts +7 -1
  11. package/dist/k8s/client.d.ts.map +1 -1
  12. package/dist/k8s/client.js +3 -1
  13. package/dist/k8s/client.js.map +1 -1
  14. package/dist/k8s/pod-status.d.ts +5 -0
  15. package/dist/k8s/pod-status.d.ts.map +1 -1
  16. package/dist/k8s/pod-status.js +7 -0
  17. package/dist/k8s/pod-status.js.map +1 -1
  18. package/dist/k8s/routing/create-or-replace.d.ts +5 -0
  19. package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
  20. package/dist/k8s/routing/create-or-replace.js +15 -0
  21. package/dist/k8s/routing/create-or-replace.js.map +1 -0
  22. package/dist/k8s/routing/gateway-router.d.ts +5 -0
  23. package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
  24. package/dist/k8s/routing/gateway-router.js +178 -0
  25. package/dist/k8s/routing/gateway-router.js.map +1 -0
  26. package/dist/k8s/routing/index.d.ts +24 -0
  27. package/dist/k8s/routing/index.d.ts.map +1 -0
  28. package/dist/k8s/routing/index.js +27 -0
  29. package/dist/k8s/routing/index.js.map +1 -0
  30. package/dist/k8s/routing/ingress-router.d.ts +13 -0
  31. package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
  32. package/dist/k8s/routing/ingress-router.js +125 -0
  33. package/dist/k8s/routing/ingress-router.js.map +1 -0
  34. package/dist/k8s/routing/route-health.d.ts +34 -0
  35. package/dist/k8s/routing/route-health.d.ts.map +1 -0
  36. package/dist/k8s/routing/route-health.js +77 -0
  37. package/dist/k8s/routing/route-health.js.map +1 -0
  38. package/dist/k8s/routing/routing-mode.d.ts +45 -0
  39. package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
  40. package/dist/k8s/routing/routing-mode.js +141 -0
  41. package/dist/k8s/routing/routing-mode.js.map +1 -0
  42. package/dist/k8s/routing/session-endpoints.d.ts +25 -0
  43. package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
  44. package/dist/k8s/routing/session-endpoints.js +36 -0
  45. package/dist/k8s/routing/session-endpoints.js.map +1 -0
  46. package/dist/k8s/routing/session-router.d.ts +57 -0
  47. package/dist/k8s/routing/session-router.d.ts.map +1 -0
  48. package/dist/k8s/routing/session-router.js +2 -0
  49. package/dist/k8s/routing/session-router.js.map +1 -0
  50. package/dist/k8s/routing/session-service.d.ts +14 -0
  51. package/dist/k8s/routing/session-service.d.ts.map +1 -0
  52. package/dist/k8s/routing/session-service.js +42 -0
  53. package/dist/k8s/routing/session-service.js.map +1 -0
  54. package/dist/k8s/watch-session.d.ts +3 -0
  55. package/dist/k8s/watch-session.d.ts.map +1 -1
  56. package/dist/k8s/watch-session.js +82 -75
  57. package/dist/k8s/watch-session.js.map +1 -1
  58. package/dist/k8s/workspace-configmap.d.ts.map +1 -1
  59. package/dist/k8s/workspace-configmap.js +3 -12
  60. package/dist/k8s/workspace-configmap.js.map +1 -1
  61. package/dist/server.d.ts.map +1 -1
  62. package/dist/server.js +34 -1
  63. package/dist/server.js.map +1 -1
  64. package/package.json +2 -2
  65. package/src/config.ts +116 -13
  66. package/src/k8s/backend.ts +73 -23
  67. package/src/k8s/client.ts +17 -1
  68. package/src/k8s/pod-status.ts +8 -0
  69. package/src/k8s/routing/create-or-replace.ts +16 -0
  70. package/src/k8s/routing/gateway-router.test.ts +236 -0
  71. package/src/k8s/routing/gateway-router.ts +219 -0
  72. package/src/k8s/routing/index.ts +44 -0
  73. package/src/k8s/routing/ingress-router.test.ts +236 -0
  74. package/src/k8s/routing/ingress-router.ts +157 -0
  75. package/src/k8s/routing/route-health.test.ts +215 -0
  76. package/src/k8s/routing/route-health.ts +95 -0
  77. package/src/k8s/routing/routing-mode.test.ts +218 -0
  78. package/src/k8s/routing/routing-mode.ts +191 -0
  79. package/src/k8s/routing/session-endpoints.ts +45 -0
  80. package/src/k8s/routing/session-router.ts +59 -0
  81. package/src/k8s/routing/session-service.ts +55 -0
  82. package/src/k8s/watch-session.ts +82 -64
  83. package/src/k8s/workspace-configmap.ts +3 -15
  84. package/src/server.ts +38 -1
  85. package/dist/k8s/ingress.d.ts +0 -15
  86. package/dist/k8s/ingress.d.ts.map +0 -1
  87. package/dist/k8s/ingress.js +0 -102
  88. package/dist/k8s/ingress.js.map +0 -1
  89. package/src/k8s/ingress.test.ts +0 -146
  90. package/src/k8s/ingress.ts +0 -127
@@ -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
+ }
@@ -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
+ });