@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.
- package/README.md +166 -107
- package/dist/bundle-store.d.ts +6 -13
- package/dist/bundle-store.d.ts.map +1 -1
- package/dist/bundle-store.js +6 -18
- package/dist/bundle-store.js.map +1 -1
- package/dist/capabilities.d.ts +3 -3
- package/dist/capabilities.js +4 -4
- package/dist/capabilities.js.map +1 -1
- package/dist/config.d.ts +47 -37
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +64 -25
- package/dist/config.js.map +1 -1
- package/dist/k8s/api-error.d.ts +34 -0
- package/dist/k8s/api-error.d.ts.map +1 -0
- package/dist/k8s/api-error.js +118 -0
- package/dist/k8s/api-error.js.map +1 -0
- package/dist/k8s/backend.d.ts +4 -0
- package/dist/k8s/backend.d.ts.map +1 -1
- package/dist/k8s/backend.js +77 -51
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/client.d.ts +7 -2
- package/dist/k8s/client.d.ts.map +1 -1
- package/dist/k8s/client.js +3 -2
- package/dist/k8s/client.js.map +1 -1
- package/dist/k8s/pod-spec.d.ts +8 -9
- package/dist/k8s/pod-spec.d.ts.map +1 -1
- package/dist/k8s/pod-spec.js +19 -29
- package/dist/k8s/pod-spec.js.map +1 -1
- package/dist/k8s/pod-status.d.ts +5 -0
- package/dist/k8s/pod-status.d.ts.map +1 -1
- package/dist/k8s/pod-status.js +9 -2
- package/dist/k8s/pod-status.js.map +1 -1
- package/dist/k8s/routing/create-or-replace.d.ts +5 -0
- package/dist/k8s/routing/create-or-replace.d.ts.map +1 -0
- package/dist/k8s/routing/create-or-replace.js +15 -0
- package/dist/k8s/routing/create-or-replace.js.map +1 -0
- package/dist/k8s/routing/gateway-router.d.ts +5 -0
- package/dist/k8s/routing/gateway-router.d.ts.map +1 -0
- package/dist/k8s/routing/gateway-router.js +178 -0
- package/dist/k8s/routing/gateway-router.js.map +1 -0
- package/dist/k8s/routing/index.d.ts +24 -0
- package/dist/k8s/routing/index.d.ts.map +1 -0
- package/dist/k8s/routing/index.js +27 -0
- package/dist/k8s/routing/index.js.map +1 -0
- package/dist/k8s/routing/ingress-router.d.ts +13 -0
- package/dist/k8s/routing/ingress-router.d.ts.map +1 -0
- package/dist/k8s/routing/ingress-router.js +125 -0
- package/dist/k8s/routing/ingress-router.js.map +1 -0
- package/dist/k8s/routing/route-health.d.ts +34 -0
- package/dist/k8s/routing/route-health.d.ts.map +1 -0
- package/dist/k8s/routing/route-health.js +77 -0
- package/dist/k8s/routing/route-health.js.map +1 -0
- package/dist/k8s/routing/routing-mode.d.ts +45 -0
- package/dist/k8s/routing/routing-mode.d.ts.map +1 -0
- package/dist/k8s/routing/routing-mode.js +141 -0
- package/dist/k8s/routing/routing-mode.js.map +1 -0
- package/dist/k8s/routing/session-endpoints.d.ts +25 -0
- package/dist/k8s/routing/session-endpoints.d.ts.map +1 -0
- package/dist/k8s/routing/session-endpoints.js +36 -0
- package/dist/k8s/routing/session-endpoints.js.map +1 -0
- package/dist/k8s/routing/session-router.d.ts +57 -0
- package/dist/k8s/routing/session-router.d.ts.map +1 -0
- package/dist/k8s/routing/session-router.js +2 -0
- package/dist/k8s/routing/session-router.js.map +1 -0
- package/dist/k8s/routing/session-service.d.ts +14 -0
- package/dist/k8s/routing/session-service.d.ts.map +1 -0
- package/dist/k8s/routing/session-service.js +42 -0
- package/dist/k8s/routing/session-service.js.map +1 -0
- package/dist/k8s/watch-session.d.ts +7 -5
- package/dist/k8s/watch-session.d.ts.map +1 -1
- package/dist/k8s/watch-session.js +90 -83
- package/dist/k8s/watch-session.js.map +1 -1
- package/dist/k8s/workspace-configmap.d.ts.map +1 -1
- package/dist/k8s/workspace-configmap.js +3 -12
- package/dist/k8s/workspace-configmap.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +37 -4
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/bundle-store.ts +6 -19
- package/src/capabilities.ts +4 -4
- package/src/config.ts +130 -68
- package/src/k8s/api-error.test.ts +89 -0
- package/src/k8s/api-error.ts +128 -0
- package/src/k8s/backend.ts +103 -58
- package/src/k8s/client.ts +10 -3
- package/src/k8s/pod-spec.test.ts +57 -2
- package/src/k8s/pod-spec.ts +25 -36
- package/src/k8s/pod-status.ts +10 -2
- package/src/k8s/routing/create-or-replace.ts +16 -0
- package/src/k8s/routing/gateway-router.test.ts +236 -0
- package/src/k8s/routing/gateway-router.ts +219 -0
- package/src/k8s/routing/index.ts +44 -0
- package/src/k8s/routing/ingress-router.test.ts +236 -0
- package/src/k8s/routing/ingress-router.ts +157 -0
- package/src/k8s/routing/route-health.test.ts +215 -0
- package/src/k8s/routing/route-health.ts +95 -0
- package/src/k8s/routing/routing-mode.test.ts +218 -0
- package/src/k8s/routing/routing-mode.ts +191 -0
- package/src/k8s/routing/session-endpoints.ts +45 -0
- package/src/k8s/routing/session-router.ts +59 -0
- package/src/k8s/routing/session-service.ts +55 -0
- package/src/k8s/watch-pod-spec.test.ts +0 -1
- package/src/k8s/watch-session.ts +90 -77
- package/src/k8s/workspace-configmap.ts +3 -15
- package/src/server.ts +41 -4
- package/dist/k8s/image-build.d.ts +0 -103
- package/dist/k8s/image-build.d.ts.map +0 -1
- package/dist/k8s/image-build.js +0 -427
- package/dist/k8s/image-build.js.map +0 -1
- package/dist/k8s/ingress.d.ts +0 -15
- package/dist/k8s/ingress.d.ts.map +0 -1
- package/dist/k8s/ingress.js +0 -102
- package/dist/k8s/ingress.js.map +0 -1
- package/src/k8s/image-build.test.ts +0 -238
- package/src/k8s/image-build.ts +0 -540
- package/src/k8s/ingress.test.ts +0 -146
- 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 { createGatewayRouter } from "./gateway-router.js";
|
|
8
|
+
|
|
9
|
+
const sessionId = "hzyayvabgyvz";
|
|
10
|
+
|
|
11
|
+
const config = {
|
|
12
|
+
sessionNamespace: "telo-sessions",
|
|
13
|
+
managedByLabel: "telo-k8s-runner",
|
|
14
|
+
sessionRouting: {
|
|
15
|
+
mode: "gateway",
|
|
16
|
+
baseDomain: "telo.run",
|
|
17
|
+
routeReadyTimeoutMs: 60_000,
|
|
18
|
+
gateway: { name: "public", namespace: "gateway-system" },
|
|
19
|
+
},
|
|
20
|
+
} as K8sRunnerConfig;
|
|
21
|
+
|
|
22
|
+
interface Recorded {
|
|
23
|
+
created: Record<string, unknown>[];
|
|
24
|
+
deleted: string[];
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A KubeClient stub carrying only what the gateway router touches. */
|
|
28
|
+
function fakeKube(opts: {
|
|
29
|
+
existing?: { metadata?: { name?: string } }[];
|
|
30
|
+
getResult?: (name: string) => unknown;
|
|
31
|
+
} = {}): { kube: KubeClient; rec: Recorded } {
|
|
32
|
+
const rec: Recorded = { created: [], deleted: [] };
|
|
33
|
+
const kube = {
|
|
34
|
+
custom: {
|
|
35
|
+
createNamespacedCustomObject: async ({ body }: { body: Record<string, unknown> }) => {
|
|
36
|
+
rec.created.push(body);
|
|
37
|
+
return body;
|
|
38
|
+
},
|
|
39
|
+
listNamespacedCustomObject: async () => ({ items: opts.existing ?? [] }),
|
|
40
|
+
deleteNamespacedCustomObject: async ({ name }: { name: string }) => {
|
|
41
|
+
rec.deleted.push(name);
|
|
42
|
+
return {};
|
|
43
|
+
},
|
|
44
|
+
getNamespacedCustomObject: async ({ name }: { name: string }) => {
|
|
45
|
+
if (opts.getResult) return opts.getResult(name);
|
|
46
|
+
throw Object.assign(new Error("not found"), { code: 404 });
|
|
47
|
+
},
|
|
48
|
+
replaceNamespacedCustomObject: async ({ body }: { body: Record<string, unknown> }) => body,
|
|
49
|
+
},
|
|
50
|
+
} as unknown as KubeClient;
|
|
51
|
+
return { kube, rec };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const ports: PortMapping[] = [
|
|
55
|
+
{ port: 8080, protocol: "tcp" },
|
|
56
|
+
{ port: 9090, protocol: "tcp" },
|
|
57
|
+
{ port: 5000, protocol: "udp" },
|
|
58
|
+
];
|
|
59
|
+
|
|
60
|
+
const publishArgs = {
|
|
61
|
+
sessionId,
|
|
62
|
+
serviceName: `telo-run-${sessionId}`,
|
|
63
|
+
podName: "pod",
|
|
64
|
+
podUid: "uid",
|
|
65
|
+
ports,
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
describe("createGatewayRouter.publish", () => {
|
|
69
|
+
it("creates ONE HTTPRoute PER PORT — hostnames are route-scoped, so a shared route would collapse every host onto one backend", async () => {
|
|
70
|
+
const { kube, rec } = fakeKube();
|
|
71
|
+
const routes = await createGatewayRouter(kube, config, "v1").publish(publishArgs);
|
|
72
|
+
|
|
73
|
+
expect(rec.created).toHaveLength(2);
|
|
74
|
+
for (const body of rec.created) {
|
|
75
|
+
const spec = body.spec as { hostnames: string[]; rules: { backendRefs: unknown[] }[] };
|
|
76
|
+
expect(spec.hostnames).toHaveLength(1);
|
|
77
|
+
expect(spec.rules).toHaveLength(1);
|
|
78
|
+
expect(spec.rules[0].backendRefs).toHaveLength(1);
|
|
79
|
+
}
|
|
80
|
+
// Each host is bound to its own port's backend, never a sibling's.
|
|
81
|
+
const pairs = rec.created.map((b) => {
|
|
82
|
+
const spec = b.spec as {
|
|
83
|
+
hostnames: string[];
|
|
84
|
+
rules: { backendRefs: { name: string; port: number }[] }[];
|
|
85
|
+
};
|
|
86
|
+
return [spec.hostnames[0], spec.rules[0].backendRefs[0].port];
|
|
87
|
+
});
|
|
88
|
+
expect(pairs).toEqual([
|
|
89
|
+
[`8080-${sessionId}.telo.run`, 8080],
|
|
90
|
+
[`9090-${sessionId}.telo.run`, 9090],
|
|
91
|
+
]);
|
|
92
|
+
expect(routes).toEqual([
|
|
93
|
+
{ host: `8080-${sessionId}.telo.run`, port: 8080 },
|
|
94
|
+
{ host: `9090-${sessionId}.telo.run`, port: 9090 },
|
|
95
|
+
]);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("skips udp ports — they are not HTTP-routable", async () => {
|
|
99
|
+
const { kube, rec } = fakeKube();
|
|
100
|
+
await createGatewayRouter(kube, config, "v1").publish({
|
|
101
|
+
...publishArgs,
|
|
102
|
+
ports: [{ port: 5000, protocol: "udp" }],
|
|
103
|
+
});
|
|
104
|
+
expect(rec.created).toEqual([]);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("attaches to the configured Gateway, carrying the listener when one is named", async () => {
|
|
108
|
+
const { kube, rec } = fakeKube();
|
|
109
|
+
const sectioned = {
|
|
110
|
+
...config,
|
|
111
|
+
sessionRouting: {
|
|
112
|
+
...config.sessionRouting,
|
|
113
|
+
gateway: { name: "public", namespace: "gateway-system", sectionName: "https" },
|
|
114
|
+
},
|
|
115
|
+
} as K8sRunnerConfig;
|
|
116
|
+
await createGatewayRouter(kube, sectioned, "v1").publish({ ...publishArgs, ports: [ports[0]] });
|
|
117
|
+
expect((rec.created[0].spec as { parentRefs: unknown[] }).parentRefs).toEqual([
|
|
118
|
+
{
|
|
119
|
+
group: "gateway.networking.k8s.io",
|
|
120
|
+
kind: "Gateway",
|
|
121
|
+
name: "public",
|
|
122
|
+
namespace: "gateway-system",
|
|
123
|
+
sectionName: "https",
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("deletes the route of a port a reload dropped — one object per port means a stale one keeps routing", async () => {
|
|
129
|
+
const { kube, rec } = fakeKube({
|
|
130
|
+
existing: [
|
|
131
|
+
{ metadata: { name: `telo-run-${sessionId}-8080` } },
|
|
132
|
+
{ metadata: { name: `telo-run-${sessionId}-9090` } },
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
await createGatewayRouter(kube, config, "v1").publish({
|
|
136
|
+
...publishArgs,
|
|
137
|
+
ports: [{ port: 8080, protocol: "tcp" }],
|
|
138
|
+
});
|
|
139
|
+
expect(rec.deleted).toEqual([`telo-run-${sessionId}-9090`]);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("posts at the served API version", async () => {
|
|
143
|
+
const { kube, rec } = fakeKube();
|
|
144
|
+
await createGatewayRouter(kube, config, "v1beta1").publish({ ...publishArgs, ports: [ports[0]] });
|
|
145
|
+
expect(rec.created[0].apiVersion).toBe("gateway.networking.k8s.io/v1beta1");
|
|
146
|
+
});
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
describe("createGatewayRouter.verdictFor", () => {
|
|
150
|
+
const route = { host: `8080-${sessionId}.telo.run`, port: 8080 };
|
|
151
|
+
|
|
152
|
+
function verdict(status: unknown) {
|
|
153
|
+
const { kube } = fakeKube({ getResult: () => ({ status }) });
|
|
154
|
+
return createGatewayRouter(kube, config, "v1").verdictFor(sessionId, route);
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
it("is pending while no controller has written status", async () => {
|
|
158
|
+
await expect(verdict(undefined)).resolves.toEqual({ kind: "pending" });
|
|
159
|
+
await expect(verdict({ parents: [] })).resolves.toEqual({ kind: "pending" });
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("is programmed once a parent accepted and resolved it", async () => {
|
|
163
|
+
await expect(
|
|
164
|
+
verdict({
|
|
165
|
+
parents: [
|
|
166
|
+
{
|
|
167
|
+
conditions: [
|
|
168
|
+
{ type: "Accepted", status: "True" },
|
|
169
|
+
{ type: "ResolvedRefs", status: "True" },
|
|
170
|
+
],
|
|
171
|
+
},
|
|
172
|
+
],
|
|
173
|
+
}),
|
|
174
|
+
).resolves.toEqual({ kind: "programmed" });
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
it("reports the controller's own reason for a refusal — the actionable half", async () => {
|
|
178
|
+
await expect(
|
|
179
|
+
verdict({
|
|
180
|
+
parents: [
|
|
181
|
+
{
|
|
182
|
+
conditions: [
|
|
183
|
+
{
|
|
184
|
+
type: "Accepted",
|
|
185
|
+
status: "False",
|
|
186
|
+
reason: "NotAllowedByListeners",
|
|
187
|
+
message: "no listener admits routes from this namespace",
|
|
188
|
+
},
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
],
|
|
192
|
+
}),
|
|
193
|
+
).resolves.toEqual({
|
|
194
|
+
kind: "rejected",
|
|
195
|
+
reason: "NotAllowedByListeners: no listener admits routes from this namespace",
|
|
196
|
+
});
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
it("reports an unresolvable backend rather than calling the route programmed", async () => {
|
|
200
|
+
await expect(
|
|
201
|
+
verdict({
|
|
202
|
+
parents: [
|
|
203
|
+
{
|
|
204
|
+
conditions: [
|
|
205
|
+
{ type: "Accepted", status: "True" },
|
|
206
|
+
{ type: "ResolvedRefs", status: "False", reason: "BackendNotFound" },
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
],
|
|
210
|
+
}),
|
|
211
|
+
).resolves.toEqual({ kind: "rejected", reason: "BackendNotFound" });
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("lets one accepting parent win over a sibling's refusal — traffic arrives either way", async () => {
|
|
215
|
+
await expect(
|
|
216
|
+
verdict({
|
|
217
|
+
parents: [
|
|
218
|
+
{ conditions: [{ type: "Accepted", status: "False", reason: "NoMatchingParent" }] },
|
|
219
|
+
{
|
|
220
|
+
conditions: [
|
|
221
|
+
{ type: "Accepted", status: "True" },
|
|
222
|
+
{ type: "ResolvedRefs", status: "True" },
|
|
223
|
+
],
|
|
224
|
+
},
|
|
225
|
+
],
|
|
226
|
+
}),
|
|
227
|
+
).resolves.toEqual({ kind: "programmed" });
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("is pending when the object is not visible yet", async () => {
|
|
231
|
+
const { kube } = fakeKube();
|
|
232
|
+
await expect(
|
|
233
|
+
createGatewayRouter(kube, config, "v1").verdictFor(sessionId, route),
|
|
234
|
+
).resolves.toEqual({ kind: "pending" });
|
|
235
|
+
});
|
|
236
|
+
});
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
import type { K8sRunnerConfig } from "../../config.js";
|
|
2
|
+
import type { KubeClient } from "../client.js";
|
|
3
|
+
import { is404 } from "../pod-status.js";
|
|
4
|
+
import { createOrReplace } from "./create-or-replace.js";
|
|
5
|
+
import { hostForPort } from "./session-endpoints.js";
|
|
6
|
+
import { podOwnerRef, sessionObjectName } from "./session-service.js";
|
|
7
|
+
import type {
|
|
8
|
+
PublishedRoute,
|
|
9
|
+
PublishRouteArgs,
|
|
10
|
+
RouteVerdict,
|
|
11
|
+
SessionRouter,
|
|
12
|
+
} from "./session-router.js";
|
|
13
|
+
|
|
14
|
+
const GROUP = "gateway.networking.k8s.io";
|
|
15
|
+
const PLURAL = "httproutes";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* ONE HTTPRoute PER PORT, and that is forced by the API rather than chosen:
|
|
19
|
+
* `hostnames` is a property of the whole HTTPRoute, while a `rule` selects on
|
|
20
|
+
* path/header/method — never on host. A single route carrying every session
|
|
21
|
+
* hostname plus one rule per port would therefore send every host to whichever
|
|
22
|
+
* rule matched `/` first, silently collapsing all of a session's ports onto one.
|
|
23
|
+
*
|
|
24
|
+
* An Ingress rule owns its host, which is why that layer needs only one object.
|
|
25
|
+
*/
|
|
26
|
+
function routeName(sessionId: string, port: number): string {
|
|
27
|
+
return `${sessionObjectName(sessionId)}-${port}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
interface RouteCondition {
|
|
31
|
+
type?: string;
|
|
32
|
+
status?: string;
|
|
33
|
+
reason?: string;
|
|
34
|
+
message?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
interface RouteParentStatus {
|
|
38
|
+
conditions?: RouteCondition[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
interface HttpRouteObject {
|
|
42
|
+
metadata?: { name?: string; resourceVersion?: string };
|
|
43
|
+
status?: { parents?: RouteParentStatus[] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function condition(parent: RouteParentStatus, type: string): RouteCondition | undefined {
|
|
47
|
+
return parent.conditions?.find((c) => c.type === type);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Phrase a refusal in the controller's own words — every cause here is fixed in
|
|
51
|
+
* the cluster (a listener that does not admit this namespace, a hostname outside
|
|
52
|
+
* the listener's own), so the controller's `reason` is the actionable part. */
|
|
53
|
+
function refusal(c: RouteCondition): string {
|
|
54
|
+
const reason = c.reason ?? "rejected";
|
|
55
|
+
return c.message ? `${reason}: ${c.message}` : reason;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function createGatewayRouter(
|
|
59
|
+
kube: KubeClient,
|
|
60
|
+
config: K8sRunnerConfig,
|
|
61
|
+
apiVersion: string,
|
|
62
|
+
): SessionRouter {
|
|
63
|
+
const ns = config.sessionNamespace;
|
|
64
|
+
const gateway = config.sessionRouting.gateway;
|
|
65
|
+
if (!gateway) {
|
|
66
|
+
// Unreachable through the factory, which refuses this at boot; kept so the
|
|
67
|
+
// invariant is stated where the field is read rather than assumed.
|
|
68
|
+
throw new Error("gateway routing selected without a configured Gateway parent");
|
|
69
|
+
}
|
|
70
|
+
const base = { group: GROUP, version: apiVersion, namespace: ns, plural: PLURAL };
|
|
71
|
+
|
|
72
|
+
// Arrow consts, not declarations: a hoisted `function` resets the narrowing of
|
|
73
|
+
// `gateway` above, since TypeScript cannot prove it is not called earlier.
|
|
74
|
+
const buildRoute = (args: PublishRouteArgs, port: number): Record<string, unknown> => {
|
|
75
|
+
return {
|
|
76
|
+
apiVersion: `${GROUP}/${apiVersion}`,
|
|
77
|
+
kind: "HTTPRoute",
|
|
78
|
+
metadata: {
|
|
79
|
+
name: routeName(args.sessionId, port),
|
|
80
|
+
namespace: ns,
|
|
81
|
+
labels: {
|
|
82
|
+
"app.kubernetes.io/managed-by": config.managedByLabel,
|
|
83
|
+
"telo.run/session-id": args.sessionId,
|
|
84
|
+
},
|
|
85
|
+
ownerReferences: [podOwnerRef(args.podName, args.podUid)],
|
|
86
|
+
},
|
|
87
|
+
spec: {
|
|
88
|
+
parentRefs: [
|
|
89
|
+
{
|
|
90
|
+
group: GROUP,
|
|
91
|
+
kind: "Gateway",
|
|
92
|
+
name: gateway.name,
|
|
93
|
+
namespace: gateway.namespace,
|
|
94
|
+
...(gateway.sectionName ? { sectionName: gateway.sectionName } : {}),
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
hostnames: [hostForPort(config, args.sessionId, port)],
|
|
98
|
+
rules: [
|
|
99
|
+
{
|
|
100
|
+
matches: [{ path: { type: "PathPrefix", value: "/" } }],
|
|
101
|
+
backendRefs: [{ name: args.serviceName, port }],
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
const listSessionRoutes = async (sessionId: string): Promise<HttpRouteObject[]> => {
|
|
109
|
+
const list = (await kube.custom.listNamespacedCustomObject({
|
|
110
|
+
...base,
|
|
111
|
+
labelSelector: `telo.run/session-id=${sessionId}`,
|
|
112
|
+
})) as { items?: HttpRouteObject[] };
|
|
113
|
+
return list.items ?? [];
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
return {
|
|
117
|
+
layer: "gateway",
|
|
118
|
+
|
|
119
|
+
async publish(args: PublishRouteArgs): Promise<PublishedRoute[]> {
|
|
120
|
+
const tcp = args.ports.filter((p) => p.protocol === "tcp");
|
|
121
|
+
const desired = new Set(tcp.map((p) => routeName(args.sessionId, p.port)));
|
|
122
|
+
|
|
123
|
+
for (const p of tcp) {
|
|
124
|
+
const body = buildRoute(args, p.port);
|
|
125
|
+
const name = routeName(args.sessionId, p.port);
|
|
126
|
+
await createOrReplace(
|
|
127
|
+
() => kube.custom.createNamespacedCustomObject({ ...base, body }),
|
|
128
|
+
async () => {
|
|
129
|
+
const existing = (await kube.custom.getNamespacedCustomObject({
|
|
130
|
+
...base,
|
|
131
|
+
name,
|
|
132
|
+
})) as HttpRouteObject;
|
|
133
|
+
await kube.custom.replaceNamespacedCustomObject({
|
|
134
|
+
...base,
|
|
135
|
+
name,
|
|
136
|
+
body: {
|
|
137
|
+
...body,
|
|
138
|
+
metadata: {
|
|
139
|
+
...(body.metadata as Record<string, unknown>),
|
|
140
|
+
resourceVersion: existing.metadata?.resourceVersion,
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// A reload that DROPS a port must delete that port's route. In ingress mode
|
|
149
|
+
// the single object is replaced and the stale rule vanishes with it; here
|
|
150
|
+
// each port owns an object, so a removed one would keep routing to a port
|
|
151
|
+
// nothing listens on.
|
|
152
|
+
for (const existing of await listSessionRoutes(args.sessionId)) {
|
|
153
|
+
const name = existing.metadata?.name;
|
|
154
|
+
if (!name || desired.has(name)) continue;
|
|
155
|
+
try {
|
|
156
|
+
await kube.custom.deleteNamespacedCustomObject({ ...base, name });
|
|
157
|
+
} catch (err) {
|
|
158
|
+
if (!is404(err)) throw err;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return tcp.map((p) => ({
|
|
163
|
+
host: hostForPort(config, args.sessionId, p.port),
|
|
164
|
+
port: p.port,
|
|
165
|
+
}));
|
|
166
|
+
},
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Gateway API is the layer that can actually answer this: a controller writes
|
|
170
|
+
* `Accepted` and `ResolvedRefs` per parent, so a route refused because the
|
|
171
|
+
* Gateway's listener does not admit this namespace says exactly that
|
|
172
|
+
* (`NotAllowedByListeners`) instead of timing out with no cause.
|
|
173
|
+
*/
|
|
174
|
+
async verdictFor(sessionId: string, route: PublishedRoute): Promise<RouteVerdict> {
|
|
175
|
+
let obj: HttpRouteObject;
|
|
176
|
+
try {
|
|
177
|
+
obj = (await kube.custom.getNamespacedCustomObject({
|
|
178
|
+
...base,
|
|
179
|
+
name: routeName(sessionId, route.port),
|
|
180
|
+
})) as HttpRouteObject;
|
|
181
|
+
} catch (err) {
|
|
182
|
+
if (is404(err)) return { kind: "pending" };
|
|
183
|
+
throw err;
|
|
184
|
+
}
|
|
185
|
+
const parents = obj.status?.parents ?? [];
|
|
186
|
+
if (parents.length === 0) return { kind: "pending" };
|
|
187
|
+
|
|
188
|
+
let rejected: string | undefined;
|
|
189
|
+
for (const parent of parents) {
|
|
190
|
+
const accepted = condition(parent, "Accepted");
|
|
191
|
+
const resolved = condition(parent, "ResolvedRefs");
|
|
192
|
+
if (accepted?.status === "False") {
|
|
193
|
+
rejected ??= refusal(accepted);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
if (resolved?.status === "False") {
|
|
197
|
+
rejected ??= refusal(resolved);
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
// A route may attach to several parents; one that programmed it is enough
|
|
201
|
+
// for traffic to arrive, so a positive verdict wins over a sibling's refusal.
|
|
202
|
+
if (accepted?.status === "True" && resolved?.status !== "False") {
|
|
203
|
+
return { kind: "programmed" };
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
return rejected ? { kind: "rejected", reason: rejected } : { kind: "pending" };
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
unclaimedReason(): string {
|
|
210
|
+
const section = gateway.sectionName ? ` (listener '${gateway.sectionName}')` : "";
|
|
211
|
+
return (
|
|
212
|
+
`no controller claimed the session HTTPRoute for Gateway ` +
|
|
213
|
+
`'${gateway.namespace}/${gateway.name}'${section}. Check the Gateway exists, is programmed, ` +
|
|
214
|
+
`and that a listener admits routes from namespace '${ns}' ` +
|
|
215
|
+
`(spec.listeners[].allowedRoutes.namespaces).`
|
|
216
|
+
);
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { K8sRunnerConfig } from "../../config.js";
|
|
2
|
+
import type { KubeClient } from "../client.js";
|
|
3
|
+
import { createGatewayRouter } from "./gateway-router.js";
|
|
4
|
+
import { createIngressRouter } from "./ingress-router.js";
|
|
5
|
+
import { resolveRouting, type ResolvedRouting } from "./routing-mode.js";
|
|
6
|
+
import type { SessionRouter } from "./session-router.js";
|
|
7
|
+
|
|
8
|
+
export { createOrReplace } from "./create-or-replace.js";
|
|
9
|
+
export { buildSessionIngress } from "./ingress-router.js";
|
|
10
|
+
export { watchRouteHealth, type WatchRouteHealthOptions } from "./route-health.js";
|
|
11
|
+
export {
|
|
12
|
+
detectRoutingSupport,
|
|
13
|
+
resolveRouting,
|
|
14
|
+
type ClusterRoutingSupport,
|
|
15
|
+
type ResolvedRouting,
|
|
16
|
+
} from "./routing-mode.js";
|
|
17
|
+
export { endpointsFor, hostForPort } from "./session-endpoints.js";
|
|
18
|
+
export type {
|
|
19
|
+
PublishedRoute,
|
|
20
|
+
PublishRouteArgs,
|
|
21
|
+
RouteVerdict,
|
|
22
|
+
SessionRouter,
|
|
23
|
+
} from "./session-router.js";
|
|
24
|
+
export { buildSessionService, podOwnerRef, sessionObjectName } from "./session-service.js";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Resolve the cluster's routing layer and build the router for it. Called ONCE at
|
|
28
|
+
* boot: an unresolvable configuration must fail the runner rather than every
|
|
29
|
+
* session it would otherwise accept and leave unroutable.
|
|
30
|
+
*
|
|
31
|
+
* `undefined` means logs-only — the honest answer when there is deliberately
|
|
32
|
+
* nothing to publish, distinct from a failure, which throws.
|
|
33
|
+
*/
|
|
34
|
+
export async function createSessionRouter(
|
|
35
|
+
kube: KubeClient,
|
|
36
|
+
config: K8sRunnerConfig,
|
|
37
|
+
): Promise<{ router?: SessionRouter; resolved: ResolvedRouting }> {
|
|
38
|
+
const resolved = await resolveRouting(kube, config);
|
|
39
|
+
if (resolved.layer === "none") return { resolved };
|
|
40
|
+
if (resolved.layer === "gateway") {
|
|
41
|
+
return { router: createGatewayRouter(kube, config, resolved.apiVersion), resolved };
|
|
42
|
+
}
|
|
43
|
+
return { router: createIngressRouter(kube, config), resolved };
|
|
44
|
+
}
|