@telorun/k8s-runner 0.14.0 → 0.15.1
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 +93 -14
- package/dist/config.d.ts +44 -9
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +51 -4
- package/dist/config.js.map +1 -1
- package/dist/k8s/backend.d.ts +4 -0
- package/dist/k8s/backend.d.ts.map +1 -1
- package/dist/k8s/backend.js +55 -17
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/client.d.ts +7 -1
- package/dist/k8s/client.d.ts.map +1 -1
- package/dist/k8s/client.js +3 -1
- package/dist/k8s/client.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 +7 -0
- 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 +3 -0
- package/dist/k8s/watch-session.d.ts.map +1 -1
- package/dist/k8s/watch-session.js +82 -75
- 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 +34 -1
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/config.ts +116 -13
- package/src/k8s/backend.ts +73 -23
- package/src/k8s/client.ts +17 -1
- package/src/k8s/pod-status.ts +8 -0
- 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-session.ts +82 -64
- package/src/k8s/workspace-configmap.ts +3 -15
- package/src/server.ts +38 -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/ingress.test.ts +0 -146
- package/src/k8s/ingress.ts +0 -127
|
@@ -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
|
+
}
|
|
@@ -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
|
+
}
|