@telorun/k8s-runner 0.6.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/LICENSE +17 -0
- package/README.md +206 -0
- package/dist/bundle-store.d.ts +32 -0
- package/dist/bundle-store.d.ts.map +1 -0
- package/dist/bundle-store.js +86 -0
- package/dist/bundle-store.js.map +1 -0
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +30 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/config.d.ts +95 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/k8s/backend.d.ts +19 -0
- package/dist/k8s/backend.d.ts.map +1 -0
- package/dist/k8s/backend.js +488 -0
- package/dist/k8s/backend.js.map +1 -0
- package/dist/k8s/client.d.ts +16 -0
- package/dist/k8s/client.d.ts.map +1 -0
- package/dist/k8s/client.js +24 -0
- package/dist/k8s/client.js.map +1 -0
- package/dist/k8s/image-build.d.ts +105 -0
- package/dist/k8s/image-build.d.ts.map +1 -0
- package/dist/k8s/image-build.js +432 -0
- package/dist/k8s/image-build.js.map +1 -0
- package/dist/k8s/ingress.d.ts +15 -0
- package/dist/k8s/ingress.d.ts.map +1 -0
- package/dist/k8s/ingress.js +102 -0
- package/dist/k8s/ingress.js.map +1 -0
- package/dist/k8s/pod-spec.d.ts +41 -0
- package/dist/k8s/pod-spec.d.ts.map +1 -0
- package/dist/k8s/pod-spec.js +146 -0
- package/dist/k8s/pod-spec.js.map +1 -0
- package/dist/limits.d.ts +25 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +58 -0
- package/dist/limits.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +93 -0
- package/dist/server.js.map +1 -0
- package/dist/tar.d.ts +10 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +63 -0
- package/dist/tar.js.map +1 -0
- package/package.json +41 -0
- package/src/bundle-store.ts +101 -0
- package/src/capabilities.ts +40 -0
- package/src/config.ts +201 -0
- package/src/k8s/backend-failure.test.ts +148 -0
- package/src/k8s/backend.ts +535 -0
- package/src/k8s/client.ts +39 -0
- package/src/k8s/image-build.test.ts +244 -0
- package/src/k8s/image-build.ts +547 -0
- package/src/k8s/ingress.test.ts +146 -0
- package/src/k8s/ingress.ts +127 -0
- package/src/k8s/pod-spec.ts +180 -0
- package/src/limits.test.ts +59 -0
- package/src/limits.ts +90 -0
- package/src/server.ts +124 -0
- package/src/tar.test.ts +55 -0
- package/src/tar.ts +68 -0
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
import { PassThrough, Writable } from "node:stream";
|
|
2
|
+
|
|
3
|
+
import type { V1ContainerState, V1ContainerStatus, V1Pod } from "@kubernetes/client-node";
|
|
4
|
+
import type {
|
|
5
|
+
AvailabilityReport,
|
|
6
|
+
BackendSession,
|
|
7
|
+
BackendStartSpec,
|
|
8
|
+
ProbeConfig,
|
|
9
|
+
RunStatus,
|
|
10
|
+
RunnerBackend,
|
|
11
|
+
} from "@telorun/runner-core";
|
|
12
|
+
import { relayDebugStream, SessionStartError } from "@telorun/runner-core";
|
|
13
|
+
|
|
14
|
+
import type { BundleStore } from "../bundle-store.js";
|
|
15
|
+
import type { K8sRunnerConfig } from "../config.js";
|
|
16
|
+
import { clampLimits } from "../limits.js";
|
|
17
|
+
import type { KubeClient } from "./client.js";
|
|
18
|
+
import { ensureSessionImage } from "./image-build.js";
|
|
19
|
+
import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
|
|
20
|
+
import { buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
|
|
21
|
+
|
|
22
|
+
/** Minimal surface of the websocket client-node's Attach returns. */
|
|
23
|
+
interface ResizableSocket {
|
|
24
|
+
send(data: Buffer): void;
|
|
25
|
+
close(): void;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const RESIZE_CHANNEL = 4;
|
|
29
|
+
/** How long a Pod may take to reach Running before the start is abandoned.
|
|
30
|
+
* activeDeadlineSeconds only bounds an already-running Pod, so a stuck
|
|
31
|
+
* Pending/unschedulable Pod needs this separate runner-side deadline. */
|
|
32
|
+
const START_DEADLINE_MS = 120_000;
|
|
33
|
+
const WATCH_REARM_DELAY_MS = 2_000;
|
|
34
|
+
|
|
35
|
+
export interface K8sBackendDeps {
|
|
36
|
+
kube: KubeClient;
|
|
37
|
+
config: K8sRunnerConfig;
|
|
38
|
+
bundleStore: BundleStore;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
42
|
+
const { kube, config, bundleStore } = deps;
|
|
43
|
+
const ns = config.sessionNamespace;
|
|
44
|
+
|
|
45
|
+
async function probe(_probe: ProbeConfig): Promise<AvailabilityReport> {
|
|
46
|
+
try {
|
|
47
|
+
await kube.core.readNamespace({ name: ns });
|
|
48
|
+
} catch {
|
|
49
|
+
const reachable = await clusterReachable(kube);
|
|
50
|
+
if (!reachable) {
|
|
51
|
+
return {
|
|
52
|
+
status: "unavailable",
|
|
53
|
+
message: "Kubernetes API server not reachable from the runner.",
|
|
54
|
+
remediation: "Check the runner's in-cluster ServiceAccount and RBAC.",
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
return {
|
|
58
|
+
status: "unavailable",
|
|
59
|
+
message: `Session namespace '${ns}' does not exist.`,
|
|
60
|
+
remediation: `Install the runner's Helm chart, which provisions the '${ns}' namespace.`,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
return { status: "ready" };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function start(spec: BackendStartSpec): Promise<BackendSession> {
|
|
67
|
+
const podName = `telo-run-${spec.sessionId}`;
|
|
68
|
+
// The /v1 contract carries no per-request limits yet, so `requested` is
|
|
69
|
+
// undefined and the configured ceiling is always the effective limit. When
|
|
70
|
+
// a control plane begins passing limits, plumb them here — the clamp is
|
|
71
|
+
// already min(requested, ceiling).
|
|
72
|
+
const limits = clampLimits(config.limits, undefined);
|
|
73
|
+
|
|
74
|
+
// Prebuild a self-contained per-app image on-cluster (controllers + module
|
|
75
|
+
// manifests baked in) and run it directly. Controller resolution never
|
|
76
|
+
// happens on the session start path, so a slow or unreachable package
|
|
77
|
+
// registry can't stall a session. Throws SessionStartError on a build
|
|
78
|
+
// failure, carrying the build pod's log tail.
|
|
79
|
+
const image = await ensureSessionImage(
|
|
80
|
+
{
|
|
81
|
+
kube,
|
|
82
|
+
build: config.build,
|
|
83
|
+
bundleStore,
|
|
84
|
+
initImage: config.initImage,
|
|
85
|
+
managedByLabel: config.managedByLabel,
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
bundle: spec.bundle,
|
|
89
|
+
entryRelativePath: spec.entryRelativePath,
|
|
90
|
+
baseImage: spec.config.image || config.defaultImage,
|
|
91
|
+
pullPolicy: spec.config.pullPolicy,
|
|
92
|
+
onProgress: (message, done) => spec.onProgress("build", message, done),
|
|
93
|
+
},
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
// The image is keyed on the dependency closure only, so deliver the
|
|
97
|
+
// per-session body to the Pod's /app at boot via a tokenized, single-use URL.
|
|
98
|
+
const bundleUrl = await bundleStore.stageSessionBundle(spec.sessionId, spec.bundle);
|
|
99
|
+
|
|
100
|
+
const pod = buildSessionPod({
|
|
101
|
+
config,
|
|
102
|
+
sessionId: spec.sessionId,
|
|
103
|
+
podName,
|
|
104
|
+
entryRelativePath: spec.entryRelativePath,
|
|
105
|
+
env: spec.env,
|
|
106
|
+
ports: spec.ports,
|
|
107
|
+
limits,
|
|
108
|
+
image,
|
|
109
|
+
bundleUrl,
|
|
110
|
+
inspect: spec.inspect,
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
let podUid: string;
|
|
114
|
+
try {
|
|
115
|
+
const created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
|
|
116
|
+
podUid = created.metadata?.uid ?? "";
|
|
117
|
+
} catch (err) {
|
|
118
|
+
throw new SessionStartError("start_failed", "create", `failed to create pod: ${msg(err)}`, msg(err));
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let finished = false;
|
|
122
|
+
let runningSeen = false;
|
|
123
|
+
let readyFlipped = false;
|
|
124
|
+
let lastProvision: string | undefined;
|
|
125
|
+
let resolveDone!: () => void;
|
|
126
|
+
const done = new Promise<void>((r) => (resolveDone = r));
|
|
127
|
+
let socket: ResizableSocket | undefined;
|
|
128
|
+
let abortWatch: () => void = () => {};
|
|
129
|
+
let startDeadline: NodeJS.Timeout | undefined;
|
|
130
|
+
let podIP: string | undefined;
|
|
131
|
+
const debugAbort = new AbortController();
|
|
132
|
+
|
|
133
|
+
const stdin = new PassThrough();
|
|
134
|
+
const stdout = new Writable({
|
|
135
|
+
write(chunk: Buffer, _enc, cb) {
|
|
136
|
+
if (chunk?.byteLength) spec.onOutput(Buffer.from(chunk));
|
|
137
|
+
cb();
|
|
138
|
+
},
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
const clearStartDeadline = (): void => {
|
|
142
|
+
if (startDeadline) {
|
|
143
|
+
clearTimeout(startDeadline);
|
|
144
|
+
startDeadline = undefined;
|
|
145
|
+
}
|
|
146
|
+
};
|
|
147
|
+
|
|
148
|
+
const finish = (status: RunStatus): void => {
|
|
149
|
+
if (finished) return;
|
|
150
|
+
finished = true;
|
|
151
|
+
clearStartDeadline();
|
|
152
|
+
abortWatch();
|
|
153
|
+
debugAbort.abort();
|
|
154
|
+
bundleStore.drop(spec.sessionId);
|
|
155
|
+
spec.onStatus(status);
|
|
156
|
+
try {
|
|
157
|
+
socket?.close();
|
|
158
|
+
} catch {
|
|
159
|
+
/* already closed */
|
|
160
|
+
}
|
|
161
|
+
resolveDone();
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
// Flip the session to `running` when the pod reaches Running. Deterministic
|
|
165
|
+
// and independent of the session image's telo version — a readiness signal
|
|
166
|
+
// would couple this to the in-image CLI (and a stale base image would never
|
|
167
|
+
// flip). The build/provision progress (streamed before this) covers the slow
|
|
168
|
+
// part; the brief post-Running validation runs while already `running`.
|
|
169
|
+
const flipRunning = (): void => {
|
|
170
|
+
if (readyFlipped || finished) return;
|
|
171
|
+
readyFlipped = true;
|
|
172
|
+
spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, spec.ports) });
|
|
173
|
+
};
|
|
174
|
+
|
|
175
|
+
let resolveRunning!: () => void;
|
|
176
|
+
let rejectRunning!: (e: Error) => void;
|
|
177
|
+
const running = new Promise<void>((res, rej) => {
|
|
178
|
+
resolveRunning = res;
|
|
179
|
+
rejectRunning = rej;
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
const handlePhase = (obj: unknown): void => {
|
|
183
|
+
if (finished) return;
|
|
184
|
+
const phase = podPhase(obj);
|
|
185
|
+
// Coming-up feed: scheduling / pulling / body delivery / container create.
|
|
186
|
+
if (!runningSeen) {
|
|
187
|
+
const provision = provisionMessage(obj);
|
|
188
|
+
if (provision && provision !== lastProvision) {
|
|
189
|
+
lastProvision = provision;
|
|
190
|
+
spec.onProgress("provision", provision);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
if (phase === "Running" && !runningSeen) {
|
|
194
|
+
runningSeen = true;
|
|
195
|
+
podIP = podStatus(obj)?.podIP;
|
|
196
|
+
clearStartDeadline();
|
|
197
|
+
resolveRunning();
|
|
198
|
+
flipRunning();
|
|
199
|
+
} else if (phase === "Succeeded") {
|
|
200
|
+
finish(terminalStatus(obj, spec.isUserStopped()));
|
|
201
|
+
} else if (phase === "Failed") {
|
|
202
|
+
if (!runningSeen) {
|
|
203
|
+
clearStartDeadline();
|
|
204
|
+
rejectRunning(new Error(podFailureMessage(obj)));
|
|
205
|
+
}
|
|
206
|
+
finish(terminalStatus(obj, spec.isUserStopped()));
|
|
207
|
+
}
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
// One-shot reconcile — covers terminal transitions that landed during a
|
|
211
|
+
// watch-reconnect gap, or a Pod that vanished entirely.
|
|
212
|
+
const reconcileOnce = async (): Promise<void> => {
|
|
213
|
+
if (finished) return;
|
|
214
|
+
try {
|
|
215
|
+
const current = await kube.core.readNamespacedPod({ name: podName, namespace: ns });
|
|
216
|
+
handlePhase(current);
|
|
217
|
+
} catch (err) {
|
|
218
|
+
if (!is404(err)) return;
|
|
219
|
+
if (!runningSeen) {
|
|
220
|
+
clearStartDeadline();
|
|
221
|
+
rejectRunning(new Error("pod disappeared before reaching Running"));
|
|
222
|
+
} else {
|
|
223
|
+
finish({ kind: "failed", message: "pod disappeared" });
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
// k8s watches expire routinely; re-arm on clean close so a healthy
|
|
229
|
+
// long-lived session (TTL up to 1h) isn't failed by a watch rollover.
|
|
230
|
+
const armWatch = async (): Promise<void> => {
|
|
231
|
+
if (finished) return;
|
|
232
|
+
try {
|
|
233
|
+
const req = await kube.watch.watch(
|
|
234
|
+
`/api/v1/namespaces/${ns}/pods`,
|
|
235
|
+
{ fieldSelector: `metadata.name=${podName}` },
|
|
236
|
+
(_type: string, obj: unknown) => handlePhase(obj),
|
|
237
|
+
() => {
|
|
238
|
+
if (finished) return;
|
|
239
|
+
void reconcileOnce().then(() => {
|
|
240
|
+
if (!finished) void armWatch();
|
|
241
|
+
});
|
|
242
|
+
},
|
|
243
|
+
);
|
|
244
|
+
abortWatch = () => {
|
|
245
|
+
try {
|
|
246
|
+
(req as { abort?: () => void }).abort?.();
|
|
247
|
+
} catch {
|
|
248
|
+
/* ignore */
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
} catch (err) {
|
|
252
|
+
if (!runningSeen) {
|
|
253
|
+
clearStartDeadline();
|
|
254
|
+
rejectRunning(new Error(`failed to watch pod: ${msg(err)}`));
|
|
255
|
+
} else if (!finished) {
|
|
256
|
+
setTimeout(() => void armWatch(), WATCH_REARM_DELAY_MS).unref?.();
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
};
|
|
260
|
+
|
|
261
|
+
startDeadline = setTimeout(() => {
|
|
262
|
+
if (!runningSeen && !finished) {
|
|
263
|
+
rejectRunning(new Error("pod did not reach Running within the start deadline"));
|
|
264
|
+
}
|
|
265
|
+
}, START_DEADLINE_MS);
|
|
266
|
+
startDeadline.unref?.();
|
|
267
|
+
|
|
268
|
+
await armWatch();
|
|
269
|
+
|
|
270
|
+
try {
|
|
271
|
+
await running;
|
|
272
|
+
} catch (err) {
|
|
273
|
+
clearStartDeadline();
|
|
274
|
+
abortWatch();
|
|
275
|
+
bundleStore.drop(spec.sessionId);
|
|
276
|
+
await deletePod(kube, ns, podName);
|
|
277
|
+
throw new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`, msg(err));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
// Attach a PTY to the running container: stdout → onOutput, stdin ← writes.
|
|
281
|
+
try {
|
|
282
|
+
const ws = await kube.attach.attach(ns, podName, "session", stdout, null, stdin, true);
|
|
283
|
+
socket = ws as unknown as ResizableSocket;
|
|
284
|
+
} catch (err) {
|
|
285
|
+
// Attach failure isn't fatal — status still flows; surface the degraded PTY.
|
|
286
|
+
spec.onOutput(Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`));
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (config.sessionIngressBaseDomain && spec.ports.length > 0) {
|
|
290
|
+
await createIngress(deps, spec.sessionId, podName, podUid, spec.ports).catch((err) => {
|
|
291
|
+
spec.onOutput(Buffer.from(`\r\n[runner] failed to create ingress: ${msg(err)}\r\n`));
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// When inspect is on, relay the workload's in-pod kernel debug stream out
|
|
296
|
+
// over `onDebug`. Reached by pod IP over the cluster network — the inspect
|
|
297
|
+
// port is never published via Service/Ingress. The Running watch event
|
|
298
|
+
// usually carries `podIP`; if it lagged, read the pod once to recover it.
|
|
299
|
+
if (spec.inspect) {
|
|
300
|
+
if (!podIP) {
|
|
301
|
+
podIP = await kube.core
|
|
302
|
+
.readNamespacedPod({ name: podName, namespace: ns })
|
|
303
|
+
.then((p) => podStatus(p)?.podIP)
|
|
304
|
+
.catch(() => undefined);
|
|
305
|
+
}
|
|
306
|
+
if (podIP) {
|
|
307
|
+
void relayDebugStream({
|
|
308
|
+
url: `http://${podIP}:${INSPECT_PORT}/events`,
|
|
309
|
+
onFrame: spec.onDebug,
|
|
310
|
+
signal: debugAbort.signal,
|
|
311
|
+
});
|
|
312
|
+
} else {
|
|
313
|
+
spec.onOutput(Buffer.from("\r\n[runner] debug stream unavailable: pod IP unknown\r\n"));
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
// `flipRunning` already announced `running` from the watch's Running
|
|
318
|
+
// transition (which `await running` above waited on).
|
|
319
|
+
return {
|
|
320
|
+
writeStdin(bytes) {
|
|
321
|
+
try {
|
|
322
|
+
stdin.write(Buffer.from(bytes));
|
|
323
|
+
} catch {
|
|
324
|
+
/* stream ended */
|
|
325
|
+
}
|
|
326
|
+
},
|
|
327
|
+
resize(cols, rows) {
|
|
328
|
+
if (!socket) return;
|
|
329
|
+
try {
|
|
330
|
+
const payload = Buffer.from(JSON.stringify({ Width: cols, Height: rows }));
|
|
331
|
+
socket.send(Buffer.concat([Buffer.from([RESIZE_CHANNEL]), payload]));
|
|
332
|
+
} catch {
|
|
333
|
+
/* socket gone */
|
|
334
|
+
}
|
|
335
|
+
},
|
|
336
|
+
done,
|
|
337
|
+
async stop() {
|
|
338
|
+
// The route sets userStopped before calling stop(), so the watch (or
|
|
339
|
+
// this finish) classifies the kill as `stopped`, not `failed`.
|
|
340
|
+
await deletePod(kube, ns, podName);
|
|
341
|
+
finish({ kind: "stopped" });
|
|
342
|
+
},
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
async function reapOrphans(): Promise<void> {
|
|
347
|
+
// The session registry is in-memory; on boot a prior process's pods are
|
|
348
|
+
// orphaned. Delete everything we own by label. Errors propagate to the
|
|
349
|
+
// caller (the server logs them) rather than being swallowed.
|
|
350
|
+
const list = await kube.core.listNamespacedPod({
|
|
351
|
+
namespace: ns,
|
|
352
|
+
labelSelector: `app.kubernetes.io/managed-by=${config.managedByLabel}`,
|
|
353
|
+
});
|
|
354
|
+
const failures: string[] = [];
|
|
355
|
+
for (const item of list.items ?? []) {
|
|
356
|
+
const name = item.metadata?.name;
|
|
357
|
+
if (!name) continue;
|
|
358
|
+
try {
|
|
359
|
+
await deletePod(kube, ns, name);
|
|
360
|
+
} catch (err) {
|
|
361
|
+
failures.push(`${name}: ${msg(err)}`);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
if (failures.length > 0) {
|
|
365
|
+
throw new Error(`failed to reap ${failures.length} orphan pod(s): ${failures.join("; ")}`);
|
|
366
|
+
}
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
return { probe, start, reapOrphans };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
async function createIngress(
|
|
373
|
+
deps: K8sBackendDeps,
|
|
374
|
+
sessionId: string,
|
|
375
|
+
podName: string,
|
|
376
|
+
podUid: string,
|
|
377
|
+
ports: BackendStartSpec["ports"],
|
|
378
|
+
): Promise<void> {
|
|
379
|
+
const { kube, config } = deps;
|
|
380
|
+
const ns = config.sessionNamespace;
|
|
381
|
+
const service = buildSessionService(config, sessionId, podName, podUid, ports);
|
|
382
|
+
await kube.core.createNamespacedService({ namespace: ns, body: service });
|
|
383
|
+
const { ingress } = buildSessionIngress(
|
|
384
|
+
config,
|
|
385
|
+
sessionId,
|
|
386
|
+
service.metadata!.name!,
|
|
387
|
+
podName,
|
|
388
|
+
podUid,
|
|
389
|
+
ports,
|
|
390
|
+
);
|
|
391
|
+
// No tcp ports → no HTTP-routable rules; the Service still exists for any udp.
|
|
392
|
+
if (!ingress.spec?.rules?.length) return;
|
|
393
|
+
await kube.networking.createNamespacedIngress({ namespace: ns, body: ingress });
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function deletePod(kube: KubeClient, ns: string, name: string): Promise<void> {
|
|
397
|
+
try {
|
|
398
|
+
await kube.core.deleteNamespacedPod({ name, namespace: ns, gracePeriodSeconds: 0 });
|
|
399
|
+
} catch (err) {
|
|
400
|
+
// 404 = already gone (natural exit + GC). Anything else is a real failure.
|
|
401
|
+
if (!is404(err)) throw err;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
async function clusterReachable(kube: KubeClient): Promise<boolean> {
|
|
406
|
+
try {
|
|
407
|
+
await kube.core.listNamespace();
|
|
408
|
+
return true;
|
|
409
|
+
} catch {
|
|
410
|
+
return false;
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
function podStatus(obj: unknown): V1Pod["status"] | undefined {
|
|
415
|
+
return (obj as V1Pod | undefined)?.status;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function podPhase(obj: unknown): string | undefined {
|
|
419
|
+
return podStatus(obj)?.phase;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
/** A coming-up message for the editor feed while the Pod is still scheduling /
|
|
423
|
+
* pulling / delivering the body / creating the container; undefined once running. */
|
|
424
|
+
function provisionMessage(obj: unknown): string | undefined {
|
|
425
|
+
const status = podStatus(obj);
|
|
426
|
+
if (status?.phase !== "Pending") return undefined;
|
|
427
|
+
const containers = [
|
|
428
|
+
...(status.initContainerStatuses ?? []),
|
|
429
|
+
...(status.containerStatuses ?? []),
|
|
430
|
+
];
|
|
431
|
+
for (const cs of containers) {
|
|
432
|
+
const reason = cs.state?.waiting?.reason;
|
|
433
|
+
if (reason) return humanizeWaitReason(reason);
|
|
434
|
+
}
|
|
435
|
+
return "Scheduling";
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
function humanizeWaitReason(reason: string): string {
|
|
439
|
+
switch (reason) {
|
|
440
|
+
case "ContainerCreating":
|
|
441
|
+
return "Creating container";
|
|
442
|
+
case "PodInitializing":
|
|
443
|
+
return "Delivering application";
|
|
444
|
+
case "ErrImagePull":
|
|
445
|
+
case "ImagePullBackOff":
|
|
446
|
+
return "Pulling image";
|
|
447
|
+
default:
|
|
448
|
+
return reason;
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function terminalStatus(obj: unknown, userStopped: boolean): RunStatus {
|
|
453
|
+
if (userStopped) return { kind: "stopped" };
|
|
454
|
+
const phase = podPhase(obj);
|
|
455
|
+
if (phase === "Succeeded") return { kind: "exited", code: containerExitCode(obj) ?? 0 };
|
|
456
|
+
return { kind: "failed", message: podFailureMessage(obj) };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
// Exit code of the main session container — used to report a clean exit.
|
|
460
|
+
function containerExitCode(obj: unknown): number | null {
|
|
461
|
+
const term = podStatus(obj)?.containerStatuses?.[0]?.state?.terminated;
|
|
462
|
+
return typeof term?.exitCode === "number" ? term.exitCode : null;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
const MAX_FAILURE_DETAIL = 500;
|
|
466
|
+
|
|
467
|
+
/**
|
|
468
|
+
* Builds an actionable failure message from a terminal Pod status. Init
|
|
469
|
+
* containers are inspected first: a failed init container leaves the main
|
|
470
|
+
* container unstarted, so reading only `containerStatuses` would fall through
|
|
471
|
+
* to the bare "pod failed". For prebuilt session pods the common failure is the
|
|
472
|
+
* main container itself (image pull, OOM, a non-zero exit).
|
|
473
|
+
*/
|
|
474
|
+
export function podFailureMessage(obj: unknown): string {
|
|
475
|
+
const status = podStatus(obj);
|
|
476
|
+
const fromContainer = firstContainerProblem(status);
|
|
477
|
+
if (fromContainer) return fromContainer;
|
|
478
|
+
if (status?.message) return truncateDetail(status.message);
|
|
479
|
+
if (status?.reason) return status.reason;
|
|
480
|
+
return "pod failed";
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function firstContainerProblem(status: V1Pod["status"] | undefined): string | undefined {
|
|
484
|
+
const groups: Array<[string, V1ContainerStatus[] | undefined]> = [
|
|
485
|
+
["init container", status?.initContainerStatuses],
|
|
486
|
+
["container", status?.containerStatuses],
|
|
487
|
+
];
|
|
488
|
+
for (const [label, statuses] of groups) {
|
|
489
|
+
for (const cs of statuses ?? []) {
|
|
490
|
+
const problem = containerStateProblem(cs.state) ?? containerStateProblem(cs.lastState);
|
|
491
|
+
if (problem) return `${label} "${cs.name}" ${problem}`;
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
return undefined;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function containerStateProblem(state: V1ContainerState | undefined): string | undefined {
|
|
498
|
+
const term = state?.terminated;
|
|
499
|
+
if (term && term.exitCode !== 0) {
|
|
500
|
+
const reason = term.reason ? `${term.reason} ` : "";
|
|
501
|
+
const detail = term.message ? `: ${truncateDetail(term.message)}` : "";
|
|
502
|
+
return `failed: ${reason}(exit code ${term.exitCode ?? "unknown"})${detail}`;
|
|
503
|
+
}
|
|
504
|
+
const waiting = state?.waiting;
|
|
505
|
+
if (waiting?.reason && isBlockingWaitReason(waiting.reason)) {
|
|
506
|
+
const detail = waiting.message ? `: ${truncateDetail(waiting.message)}` : "";
|
|
507
|
+
return `waiting: ${waiting.reason}${detail}`;
|
|
508
|
+
}
|
|
509
|
+
return undefined;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
// Benign transient reasons the kubelet reports while a Pod is still coming up.
|
|
513
|
+
function isBlockingWaitReason(reason: string): boolean {
|
|
514
|
+
return reason !== "PodInitializing" && reason !== "ContainerCreating";
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
function truncateDetail(text: string): string {
|
|
518
|
+
const trimmed = text.trim();
|
|
519
|
+
return trimmed.length > MAX_FAILURE_DETAIL ? `${trimmed.slice(0, MAX_FAILURE_DETAIL)}…` : trimmed;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function is404(err: unknown): boolean {
|
|
523
|
+
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
524
|
+
return e?.statusCode === 404 || e?.code === 404 || e?.response?.statusCode === 404;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function msg(err: unknown): string {
|
|
528
|
+
if (err instanceof Error) return err.message;
|
|
529
|
+
if (typeof err === "string") return err;
|
|
530
|
+
try {
|
|
531
|
+
return JSON.stringify(err);
|
|
532
|
+
} catch {
|
|
533
|
+
return String(err);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import {
|
|
2
|
+
Attach,
|
|
3
|
+
BatchV1Api,
|
|
4
|
+
CoreV1Api,
|
|
5
|
+
KubeConfig,
|
|
6
|
+
NetworkingV1Api,
|
|
7
|
+
Watch,
|
|
8
|
+
} from "@kubernetes/client-node";
|
|
9
|
+
|
|
10
|
+
export interface KubeClient {
|
|
11
|
+
kc: KubeConfig;
|
|
12
|
+
core: CoreV1Api;
|
|
13
|
+
batch: BatchV1Api;
|
|
14
|
+
networking: NetworkingV1Api;
|
|
15
|
+
attach: Attach;
|
|
16
|
+
watch: Watch;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Loads kube config — in-cluster (projected ServiceAccount token + CA) when
|
|
21
|
+
* running as a Pod, falling back to the local kubeconfig for out-of-cluster
|
|
22
|
+
* development.
|
|
23
|
+
*/
|
|
24
|
+
export function createKubeClient(): KubeClient {
|
|
25
|
+
const kc = new KubeConfig();
|
|
26
|
+
if (process.env.KUBERNETES_SERVICE_HOST) {
|
|
27
|
+
kc.loadFromCluster();
|
|
28
|
+
} else {
|
|
29
|
+
kc.loadFromDefault();
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
kc,
|
|
33
|
+
core: kc.makeApiClient(CoreV1Api),
|
|
34
|
+
batch: kc.makeApiClient(BatchV1Api),
|
|
35
|
+
networking: kc.makeApiClient(NetworkingV1Api),
|
|
36
|
+
attach: new Attach(kc),
|
|
37
|
+
watch: new Watch(kc),
|
|
38
|
+
};
|
|
39
|
+
}
|