@telorun/k8s-runner 0.13.0 → 0.14.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 +72 -92
- 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 +7 -32
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +13 -21
- 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.map +1 -1
- package/dist/k8s/backend.js +23 -35
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/client.d.ts +1 -2
- package/dist/k8s/client.d.ts.map +1 -1
- package/dist/k8s/client.js +1 -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.map +1 -1
- package/dist/k8s/pod-status.js +2 -2
- package/dist/k8s/pod-status.js.map +1 -1
- package/dist/k8s/watch-session.d.ts +4 -5
- package/dist/k8s/watch-session.d.ts.map +1 -1
- package/dist/k8s/watch-session.js +8 -8
- package/dist/k8s/watch-session.js.map +1 -1
- package/dist/server.js +3 -3
- 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 +20 -61
- package/src/k8s/api-error.test.ts +89 -0
- package/src/k8s/api-error.ts +128 -0
- package/src/k8s/backend.ts +34 -39
- package/src/k8s/client.ts +1 -10
- package/src/k8s/pod-spec.test.ts +57 -2
- package/src/k8s/pod-spec.ts +25 -36
- package/src/k8s/pod-status.ts +2 -2
- package/src/k8s/watch-pod-spec.test.ts +0 -1
- package/src/k8s/watch-session.ts +8 -13
- package/src/server.ts +3 -3
- 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/src/k8s/image-build.test.ts +0 -238
- package/src/k8s/image-build.ts +0 -540
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { SessionStartError, type StartFailureStage } from "@telorun/runner-core";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Kubernetes API rejection, phrased for the person who clicked Run.
|
|
5
|
+
*
|
|
6
|
+
* `ApiException.message` is a full HTTP dump — status line, the raw `Status`
|
|
7
|
+
* body and every response header — and a start failure's message travels
|
|
8
|
+
* verbatim to the client as the session's terminal `failed` status. That put
|
|
9
|
+
* the runner's ServiceAccount name, the request's audit id and its flowschema
|
|
10
|
+
* UIDs on an end user's screen, none of which they can act on.
|
|
11
|
+
*
|
|
12
|
+
* So the client sees the operation, the HTTP status and the API's own one-word
|
|
13
|
+
* `reason` — enough for an operator to know what was refused — and never the
|
|
14
|
+
* body or the headers. The raw exception rides along as the error's `cause`,
|
|
15
|
+
* which the runner's log serializer records, so nothing is swallowed: the detail
|
|
16
|
+
* moves to the log, it does not disappear.
|
|
17
|
+
*/
|
|
18
|
+
export function apiFailure(
|
|
19
|
+
err: unknown,
|
|
20
|
+
stage: StartFailureStage,
|
|
21
|
+
action: string,
|
|
22
|
+
): SessionStartError {
|
|
23
|
+
return withCause(
|
|
24
|
+
new SessionStartError("start_failed", stage, `${action}: ${apiReason(err)}`),
|
|
25
|
+
err,
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Attach the original error as `cause` so a log serializer can reach it.
|
|
30
|
+
* Every wrap on a failure path goes through here — a rewrap that drops the
|
|
31
|
+
* cause silently undoes the whole point of summarizing the message. */
|
|
32
|
+
export function withCause<E extends Error>(error: E, cause: unknown): E {
|
|
33
|
+
error.cause = cause;
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The client-safe half of a Kubernetes API error: what was refused, and who
|
|
38
|
+
* can act on it — never the `Status` message, which names cluster identities. */
|
|
39
|
+
export function apiReason(err: unknown): string {
|
|
40
|
+
const code = statusCode(err);
|
|
41
|
+
// No HTTP status means the request never got an answer: a refused connection,
|
|
42
|
+
// a DNS failure, an abort. Those carry a `code` too (`ECONNREFUSED`, an
|
|
43
|
+
// `ABORT_ERR` number), which is why `statusCode` accepts only what an HTTP
|
|
44
|
+
// response could have produced — reporting `HTTP ECONNREFUSED` claimed the
|
|
45
|
+
// apiserver rejected something when it was never reached, which is exactly
|
|
46
|
+
// the case where the difference matters.
|
|
47
|
+
if (code === undefined) return "the Kubernetes API could not be reached";
|
|
48
|
+
|
|
49
|
+
const reason = statusReason(err);
|
|
50
|
+
const detail = reason ? `${reason}, HTTP ${code}` : `HTTP ${code}`;
|
|
51
|
+
const cause = refusalCause(err);
|
|
52
|
+
if (cause === "quota") {
|
|
53
|
+
return `the session namespace's resource quota is exhausted (${detail})`;
|
|
54
|
+
}
|
|
55
|
+
if (cause === "rbac") {
|
|
56
|
+
return `the runner is not permitted to do this (${detail}). The cluster operator must check the runner's RBAC.`;
|
|
57
|
+
}
|
|
58
|
+
return `the Kubernetes API rejected the request (${detail})`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Which of the two things a `403 Forbidden` means here.
|
|
63
|
+
*
|
|
64
|
+
* The status code alone cannot say: an RBAC denial and a `ResourceQuota`
|
|
65
|
+
* rejection are both `403` with `reason: Forbidden`, and this chart ships a
|
|
66
|
+
* quota (32 pods) enabled by default — so blaming RBAC on every 403 tells the
|
|
67
|
+
* operator to re-check a Role at exactly the moment the cluster is simply full.
|
|
68
|
+
* The two also differ in when they happen: an RBAC 403 is an install-time
|
|
69
|
+
* mistake, a quota 403 arrives under load.
|
|
70
|
+
*
|
|
71
|
+
* The only thing that separates them is the admission message, which is why it
|
|
72
|
+
* is READ here and never echoed: the classification crosses the boundary, the
|
|
73
|
+
* text does not. An unrecognized 403 degrades to the neutral wording rather than
|
|
74
|
+
* to a guess.
|
|
75
|
+
*/
|
|
76
|
+
function refusalCause(err: unknown): "quota" | "rbac" | undefined {
|
|
77
|
+
const code = statusCode(err);
|
|
78
|
+
if (code !== 401 && code !== 403) return undefined;
|
|
79
|
+
const message = statusMessage(err);
|
|
80
|
+
if (message && /exceeded quota|forbidden: failed quota/i.test(message)) return "quota";
|
|
81
|
+
if (!message || /\bis forbidden: User\b|\bcannot \w+ resource\b/i.test(message)) return "rbac";
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** The API `Status` object, however the client-node deserializer left it: a
|
|
86
|
+
* parsed object for a status code it recognizes, the raw JSON text for one it
|
|
87
|
+
* does not (which is the 403 case, reported as "Unknown API Status Code!"). */
|
|
88
|
+
function status(err: unknown): Record<string, unknown> | undefined {
|
|
89
|
+
const body = (err as { body?: unknown })?.body;
|
|
90
|
+
const parsed = typeof body === "string" ? parseJson(body) : body;
|
|
91
|
+
return parsed && typeof parsed === "object" ? (parsed as Record<string, unknown>) : undefined;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** `Forbidden`, `NotFound`, `Invalid`, … — the one word safe to pass on. */
|
|
95
|
+
function statusReason(err: unknown): string | undefined {
|
|
96
|
+
const reason = status(err)?.reason;
|
|
97
|
+
return typeof reason === "string" && reason.trim() !== "" ? reason : undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Read for classification only — it names cluster identities and never leaves. */
|
|
101
|
+
function statusMessage(err: unknown): string | undefined {
|
|
102
|
+
const message = status(err)?.message;
|
|
103
|
+
return typeof message === "string" ? message : undefined;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function parseJson(text: string): unknown {
|
|
107
|
+
try {
|
|
108
|
+
return JSON.parse(text);
|
|
109
|
+
} catch {
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The HTTP status an error carries, or `undefined` when it carries none.
|
|
116
|
+
*
|
|
117
|
+
* The number check is the whole point: `code` is also where Node puts a system
|
|
118
|
+
* error's string code and where a `DOMException` puts its legacy numeric one, so
|
|
119
|
+
* an unreachable apiserver and an aborted request both arrive here looking like
|
|
120
|
+
* a status. Only a value in the HTTP range is one.
|
|
121
|
+
*/
|
|
122
|
+
export function statusCode(err: unknown): number | undefined {
|
|
123
|
+
const e = err as { statusCode?: unknown; code?: unknown; response?: { statusCode?: unknown } };
|
|
124
|
+
for (const candidate of [e?.statusCode, e?.code, e?.response?.statusCode]) {
|
|
125
|
+
if (typeof candidate === "number" && candidate >= 100 && candidate <= 599) return candidate;
|
|
126
|
+
}
|
|
127
|
+
return undefined;
|
|
128
|
+
}
|
package/src/k8s/backend.ts
CHANGED
|
@@ -15,8 +15,8 @@ import { relayDebugStream, SessionStartError, watchReachability } from "@telorun
|
|
|
15
15
|
import type { BundleStore } from "../bundle-store.js";
|
|
16
16
|
import type { K8sRunnerConfig } from "../config.js";
|
|
17
17
|
import { clampLimits } from "../limits.js";
|
|
18
|
+
import { apiFailure, apiReason, withCause } from "./api-error.js";
|
|
18
19
|
import type { KubeClient } from "./client.js";
|
|
19
|
-
import { ensureSessionImage } from "./image-build.js";
|
|
20
20
|
import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
|
|
21
21
|
import { buildAppPod, buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
|
|
22
22
|
import {
|
|
@@ -80,9 +80,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
80
80
|
async function start(spec: BackendStartSpec): Promise<BackendSession> {
|
|
81
81
|
// A watch session is a different pod shape and a different lifetime — it
|
|
82
82
|
// outlives its runs — so it takes its own path rather than accreting
|
|
83
|
-
// branches through this one.
|
|
84
|
-
// exists to put a dependency closure on disk before boot, and a watch
|
|
85
|
-
// session fetches its own and keeps it for the pod's life.
|
|
83
|
+
// branches through this one.
|
|
86
84
|
if (spec.mode === "watch") {
|
|
87
85
|
return startWatchSession({ kube, config }, spec);
|
|
88
86
|
}
|
|
@@ -104,9 +102,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
104
102
|
|
|
105
103
|
let pod: V1Pod;
|
|
106
104
|
if (spec.selfContained) {
|
|
107
|
-
// Operator-predefined app (catalog image): self-contained, no
|
|
108
|
-
//
|
|
109
|
-
//
|
|
105
|
+
// Operator-predefined app (catalog image): self-contained, with no bundle
|
|
106
|
+
// to deliver — the pod runs the image's own entrypoint with the env the
|
|
107
|
+
// core route already merged.
|
|
110
108
|
pod = buildAppPod({
|
|
111
109
|
config,
|
|
112
110
|
sessionId: spec.sessionId,
|
|
@@ -118,30 +116,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
118
116
|
pullPolicy: spec.config.pullPolicy,
|
|
119
117
|
});
|
|
120
118
|
} else {
|
|
121
|
-
//
|
|
122
|
-
//
|
|
123
|
-
//
|
|
124
|
-
// registry can't stall a session. Throws SessionStartError on a build
|
|
125
|
-
// failure, carrying the build pod's log tail.
|
|
126
|
-
const image = await ensureSessionImage(
|
|
127
|
-
{
|
|
128
|
-
kube,
|
|
129
|
-
build: config.build,
|
|
130
|
-
bundleStore,
|
|
131
|
-
initImage: config.initImage,
|
|
132
|
-
managedByLabel: config.managedByLabel,
|
|
133
|
-
},
|
|
134
|
-
{
|
|
135
|
-
bundle: spec.bundle,
|
|
136
|
-
entryRelativePath: app.entryRelativePath,
|
|
137
|
-
baseImage: spec.config.image || config.defaultImage,
|
|
138
|
-
pullPolicy: spec.config.pullPolicy,
|
|
139
|
-
onProgress: (message, done) => spec.onProgress("build", message, done, appName),
|
|
140
|
-
},
|
|
141
|
-
);
|
|
142
|
-
|
|
143
|
-
// The image is keyed on the dependency closure only, so deliver the
|
|
144
|
-
// per-session body to the Pod's /app at boot via a tokenized, single-use URL.
|
|
119
|
+
// Deliver the body to the Pod's /app at boot via a tokenized, single-use
|
|
120
|
+
// URL, and run it on the plain kernel image: the kernel resolves its own
|
|
121
|
+
// module closure into the pod's cache emptyDir on the way up.
|
|
145
122
|
const bundleUrl = await bundleStore.stageSessionBundle(spec.sessionId, spec.bundle);
|
|
146
123
|
|
|
147
124
|
pod = buildSessionPod({
|
|
@@ -152,7 +129,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
152
129
|
env: spec.env,
|
|
153
130
|
ports: app.ports,
|
|
154
131
|
limits,
|
|
155
|
-
image,
|
|
132
|
+
image: spec.config.image || config.defaultImage,
|
|
133
|
+
pullPolicy: spec.config.pullPolicy,
|
|
156
134
|
bundleUrl,
|
|
157
135
|
inspect: spec.inspect,
|
|
158
136
|
});
|
|
@@ -163,7 +141,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
163
141
|
const created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
|
|
164
142
|
podUid = created.metadata?.uid ?? "";
|
|
165
143
|
} catch (err) {
|
|
166
|
-
throw
|
|
144
|
+
throw apiFailure(err, "create", "could not create the session pod");
|
|
167
145
|
}
|
|
168
146
|
|
|
169
147
|
let finished = false;
|
|
@@ -214,8 +192,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
214
192
|
// Flip the session to `running` when the pod reaches Running. Deterministic
|
|
215
193
|
// and independent of the session image's telo version — a readiness signal
|
|
216
194
|
// would couple this to the in-image CLI (and a stale base image would never
|
|
217
|
-
// flip). The
|
|
218
|
-
//
|
|
195
|
+
// flip). The provision progress (streamed before this) covers the slow part;
|
|
196
|
+
// module resolution and validation run while already `running`.
|
|
219
197
|
const flipRunning = (): void => {
|
|
220
198
|
if (readyFlipped || finished) return;
|
|
221
199
|
readyFlipped = true;
|
|
@@ -301,7 +279,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
301
279
|
} catch (err) {
|
|
302
280
|
if (!runningSeen) {
|
|
303
281
|
clearStartDeadline();
|
|
304
|
-
rejectRunning(new Error(`
|
|
282
|
+
rejectRunning(new Error(`could not watch the session pod: ${apiReason(err)}`, { cause: err }));
|
|
305
283
|
} else if (!finished) {
|
|
306
284
|
setTimeout(() => void armWatch(), WATCH_REARM_DELAY_MS).unref?.();
|
|
307
285
|
}
|
|
@@ -324,7 +302,16 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
324
302
|
abortWatch();
|
|
325
303
|
bundleStore.drop(spec.sessionId);
|
|
326
304
|
await deletePod(kube, ns, podName);
|
|
327
|
-
|
|
305
|
+
// `err` is one of OUR errors — a pod-status failure message, a
|
|
306
|
+
// disappeared pod, the start deadline, a watch that could not be armed —
|
|
307
|
+
// so its message is already client-safe and stands. What must not be lost
|
|
308
|
+
// is its `cause`: the watch path attaches the raw ApiException there, and
|
|
309
|
+
// rewrapping without forwarding it drops the very detail the summarized
|
|
310
|
+
// message exists to relocate into the log.
|
|
311
|
+
throw withCause(
|
|
312
|
+
new SessionStartError("start_failed", "start", `pod failed to start: ${msg(err)}`),
|
|
313
|
+
(err as { cause?: unknown })?.cause ?? err,
|
|
314
|
+
);
|
|
328
315
|
}
|
|
329
316
|
|
|
330
317
|
// Attach a PTY to the running container: stdout → onOutput, stdin ← writes.
|
|
@@ -333,12 +320,20 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
333
320
|
socket = ws as unknown as ResizableSocket;
|
|
334
321
|
} catch (err) {
|
|
335
322
|
// Attach failure isn't fatal — status still flows; surface the degraded PTY.
|
|
336
|
-
spec.onOutput(
|
|
323
|
+
spec.onOutput(
|
|
324
|
+
appName,
|
|
325
|
+
Buffer.from(`\r\n[runner] failed to attach PTY: ${apiReason(err)}\r\n`),
|
|
326
|
+
"tty",
|
|
327
|
+
);
|
|
337
328
|
}
|
|
338
329
|
|
|
339
330
|
if (config.sessionIngressBaseDomain && app.ports.length > 0) {
|
|
340
331
|
await createIngress(deps, spec.sessionId, podName, podUid, app.ports).catch((err) => {
|
|
341
|
-
spec.onOutput(
|
|
332
|
+
spec.onOutput(
|
|
333
|
+
appName,
|
|
334
|
+
Buffer.from(`\r\n[runner] failed to create ingress: ${apiReason(err)}\r\n`),
|
|
335
|
+
"tty",
|
|
336
|
+
);
|
|
342
337
|
});
|
|
343
338
|
}
|
|
344
339
|
|
package/src/k8s/client.ts
CHANGED
|
@@ -1,16 +1,8 @@
|
|
|
1
|
-
import {
|
|
2
|
-
Attach,
|
|
3
|
-
BatchV1Api,
|
|
4
|
-
CoreV1Api,
|
|
5
|
-
KubeConfig,
|
|
6
|
-
NetworkingV1Api,
|
|
7
|
-
Watch,
|
|
8
|
-
} from "@kubernetes/client-node";
|
|
1
|
+
import { Attach, CoreV1Api, KubeConfig, NetworkingV1Api, Watch } from "@kubernetes/client-node";
|
|
9
2
|
|
|
10
3
|
export interface KubeClient {
|
|
11
4
|
kc: KubeConfig;
|
|
12
5
|
core: CoreV1Api;
|
|
13
|
-
batch: BatchV1Api;
|
|
14
6
|
networking: NetworkingV1Api;
|
|
15
7
|
attach: Attach;
|
|
16
8
|
watch: Watch;
|
|
@@ -31,7 +23,6 @@ export function createKubeClient(): KubeClient {
|
|
|
31
23
|
return {
|
|
32
24
|
kc,
|
|
33
25
|
core: kc.makeApiClient(CoreV1Api),
|
|
34
|
-
batch: kc.makeApiClient(BatchV1Api),
|
|
35
26
|
networking: kc.makeApiClient(NetworkingV1Api),
|
|
36
27
|
attach: new Attach(kc),
|
|
37
28
|
watch: new Watch(kc),
|
package/src/k8s/pod-spec.test.ts
CHANGED
|
@@ -1,13 +1,68 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest";
|
|
2
2
|
|
|
3
3
|
import { loadK8sRunnerConfig } from "../config.js";
|
|
4
|
-
import { buildAppPod } from "./pod-spec.js";
|
|
4
|
+
import { buildAppPod, buildSessionPod } from "./pod-spec.js";
|
|
5
5
|
|
|
6
6
|
const BASE_ENV = {
|
|
7
7
|
RUNNER_SELF_URL: "http://k8s-runner.telo-runner.svc:8062",
|
|
8
|
-
RUNNER_IMAGE_REPOSITORY: "registry.telo-runner.svc:5000/telo-sessions",
|
|
9
8
|
};
|
|
10
9
|
|
|
10
|
+
describe("buildSessionPod", () => {
|
|
11
|
+
const config = loadK8sRunnerConfig({ ...process.env, ...BASE_ENV });
|
|
12
|
+
const pod = buildSessionPod({
|
|
13
|
+
config,
|
|
14
|
+
sessionId: "abc123",
|
|
15
|
+
podName: "telo-run-abc123",
|
|
16
|
+
entryRelativePath: "telo.yaml",
|
|
17
|
+
env: { API_TOKEN: "tok" },
|
|
18
|
+
ports: [{ port: 3000, protocol: "tcp" }],
|
|
19
|
+
limits: config.limits,
|
|
20
|
+
image: "telorun/node:0.30.1-slim",
|
|
21
|
+
pullPolicy: "always",
|
|
22
|
+
bundleUrl: "http://k8s-runner.telo-runner.svc:8062/internal/bundles/abc123?token=t",
|
|
23
|
+
inspect: false,
|
|
24
|
+
});
|
|
25
|
+
const container = pod.spec!.containers[0];
|
|
26
|
+
|
|
27
|
+
it("runs the picked kernel image under the requested pull policy", () => {
|
|
28
|
+
expect(container.image).toBe("telorun/node:0.30.1-slim");
|
|
29
|
+
expect(container.imagePullPolicy).toBe("Always");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("resolves the module closure at boot into a writable cache", () => {
|
|
33
|
+
// No prebuilt image means no baked deps: the cache has to be a mounted
|
|
34
|
+
// emptyDir, and `telo run` has to be allowed to write it.
|
|
35
|
+
expect(container.env).toContainEqual({ name: "TELO_CACHE_DIR", value: "/telo-cache" });
|
|
36
|
+
expect(container.volumeMounts).toContainEqual({
|
|
37
|
+
name: "telo-cache",
|
|
38
|
+
mountPath: "/telo-cache",
|
|
39
|
+
});
|
|
40
|
+
expect(pod.spec!.volumes).toContainEqual({ name: "telo-cache", emptyDir: {} });
|
|
41
|
+
expect(container.command).toEqual(["telo", "run", "/app/telo.yaml"]);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("is given headroom to resolve a closure, not just to run one", () => {
|
|
45
|
+
// The ceilings used to describe a pod that only RAN a prebuilt image (50m /
|
|
46
|
+
// 100Mi / 512Mi). It now downloads, unpacks and resolves the closure itself,
|
|
47
|
+
// into an emptyDir charged against ephemeral-storage — the old numbers were
|
|
48
|
+
// an OOMKill and an eviction for the ordinary case. Asserted against the
|
|
49
|
+
// app-session ceilings, which is the tier the equivalent watch container
|
|
50
|
+
// already ran under.
|
|
51
|
+
expect(container.resources?.limits?.memory).toBe(config.appLimits.memory);
|
|
52
|
+
expect(container.resources?.limits?.cpu).toBe(config.appLimits.cpu);
|
|
53
|
+
expect(container.resources?.limits?.["ephemeral-storage"]).toBe(
|
|
54
|
+
config.appLimits.ephemeralStorage,
|
|
55
|
+
);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("delivers the body over the initContainer and keeps the rootfs read-only", () => {
|
|
59
|
+
const init = pod.spec!.initContainers![0];
|
|
60
|
+
expect(init.args?.[0]).toContain("/internal/bundles/abc123?token=t");
|
|
61
|
+
expect(container.securityContext).toMatchObject({ readOnlyRootFilesystem: true });
|
|
62
|
+
expect(pod.spec!.automountServiceAccountToken).toBe(false);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
65
|
+
|
|
11
66
|
describe("buildAppPod", () => {
|
|
12
67
|
const config = loadK8sRunnerConfig({ ...process.env, ...BASE_ENV });
|
|
13
68
|
const pod = buildAppPod({
|
package/src/k8s/pod-spec.ts
CHANGED
|
@@ -17,11 +17,11 @@ export interface BuildPodArgs {
|
|
|
17
17
|
env: Record<string, string>;
|
|
18
18
|
ports: PortMapping[];
|
|
19
19
|
limits: ResolvedLimits;
|
|
20
|
-
/**
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
* is NOT baked — it's delivered to `/app` at boot via the initContainer. */
|
|
20
|
+
/** The kernel image to run (`telorun/node`) — the session's picked base image,
|
|
21
|
+
* or the runner's default. Nothing is baked per app: the body arrives over the
|
|
22
|
+
* initContainer and the kernel resolves its module closure at boot. */
|
|
24
23
|
image: string;
|
|
24
|
+
pullPolicy: PullPolicy;
|
|
25
25
|
/** Tokenized, single-use URL the body-delivery initContainer fetches the
|
|
26
26
|
* session bundle tarball from (`BundleStore.stageSessionBundle`). */
|
|
27
27
|
bundleUrl: string;
|
|
@@ -38,12 +38,11 @@ export const INSPECT_PORT = 9230;
|
|
|
38
38
|
|
|
39
39
|
const APP_DIR = "/app";
|
|
40
40
|
const WORK_DIR = "/work";
|
|
41
|
-
/**
|
|
42
|
-
*
|
|
43
|
-
*
|
|
41
|
+
/** The kernel's module cache for this session. A writable emptyDir: the closure
|
|
42
|
+
* is resolved at boot and lives as long as the pod, so a re-run of the same app
|
|
43
|
+
* is a fresh download — the price of having no prebuilt image. */
|
|
44
44
|
const DEPS_DIR = "/telo-cache";
|
|
45
|
-
/** Writable HOME / npm scratch under a read-only rootfs.
|
|
46
|
-
* (which is now read-only baked deps, not scratch). */
|
|
45
|
+
/** Writable HOME / npm scratch under a read-only rootfs. */
|
|
47
46
|
const HOME_DIR = "/home/telo";
|
|
48
47
|
const TMP_MOUNT = "/tmp";
|
|
49
48
|
|
|
@@ -53,8 +52,8 @@ const TMP_MOUNT = "/tmp";
|
|
|
53
52
|
* seccomp RuntimeDefault); a sandbox RuntimeClass is layered on when configured.
|
|
54
53
|
*
|
|
55
54
|
* The body-delivery initContainer fetches the session bundle into the writable
|
|
56
|
-
* `/app` emptyDir; the session container runs `telo run /app/<entry
|
|
57
|
-
*
|
|
55
|
+
* `/app` emptyDir; the session container runs `telo run /app/<entry>` on the
|
|
56
|
+
* plain kernel image and resolves its module closure into `/telo-cache`.
|
|
58
57
|
* `readOnlyRootFilesystem` stays on — every write lands on a mounted emptyDir.
|
|
59
58
|
*/
|
|
60
59
|
export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
@@ -73,8 +72,8 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
73
72
|
};
|
|
74
73
|
|
|
75
74
|
const envVars = Object.entries(args.env).map(([name, value]) => ({ name, value }));
|
|
76
|
-
//
|
|
77
|
-
// separate
|
|
75
|
+
// Resolve deps into the session's own `/telo-cache` emptyDir; keep HOME/npm
|
|
76
|
+
// scratch on a separate one under the read-only root filesystem.
|
|
78
77
|
envVars.push({ name: "TELO_CACHE_DIR", value: DEPS_DIR });
|
|
79
78
|
envVars.push({ name: "HOME", value: HOME_DIR });
|
|
80
79
|
envVars.push({ name: "npm_config_cache", value: `${HOME_DIR}/.npm` });
|
|
@@ -95,11 +94,9 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
95
94
|
restartPolicy: "Never",
|
|
96
95
|
activeDeadlineSeconds: limits.ttlSeconds,
|
|
97
96
|
automountServiceAccountToken: false,
|
|
98
|
-
// Pull the
|
|
97
|
+
// Pull the kernel image from a private registry. The Secret must exist in
|
|
99
98
|
// the session namespace (pull secrets are namespace-scoped).
|
|
100
|
-
...(config.
|
|
101
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
102
|
-
: {}),
|
|
99
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
103
100
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
104
101
|
securityContext: {
|
|
105
102
|
runAsNonRoot: true,
|
|
@@ -110,8 +107,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
110
107
|
},
|
|
111
108
|
initContainers: [
|
|
112
109
|
{
|
|
113
|
-
// Deliver the
|
|
114
|
-
// image bakes only the dependency closure, so the body arrives here.
|
|
110
|
+
// Deliver the session body into the writable /app emptyDir.
|
|
115
111
|
name: "body-fetch",
|
|
116
112
|
image: config.initImage,
|
|
117
113
|
command: ["sh", "-c"],
|
|
@@ -129,13 +125,10 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
129
125
|
{
|
|
130
126
|
name: "session",
|
|
131
127
|
image: args.image,
|
|
132
|
-
|
|
133
|
-
// the
|
|
134
|
-
|
|
135
|
-
//
|
|
136
|
-
// the baked deps from TELO_CACHE_DIR and validates in-memory without
|
|
137
|
-
// touching the read-only cache. WORK_DIR is a writable emptyDir cwd so
|
|
138
|
-
// the workload's relative paths resolve under readOnlyRootFilesystem.
|
|
128
|
+
imagePullPolicy: pullPolicyToK8s(args.pullPolicy),
|
|
129
|
+
// Run the delivered body by absolute path. WORK_DIR is a writable
|
|
130
|
+
// emptyDir cwd so the workload's relative paths resolve under
|
|
131
|
+
// readOnlyRootFilesystem.
|
|
139
132
|
workingDir: WORK_DIR,
|
|
140
133
|
// 0.0.0.0 (not the CLI's loopback default) lets the runner reach the
|
|
141
134
|
// debug server across the pod network; the port is never published.
|
|
@@ -143,7 +136,6 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
143
136
|
"telo",
|
|
144
137
|
"run",
|
|
145
138
|
`${APP_DIR}/${args.entryRelativePath}`,
|
|
146
|
-
"--no-cache-write",
|
|
147
139
|
...(args.inspect ? ["--inspect", `0.0.0.0:${INSPECT_PORT}`, "--no-open"] : []),
|
|
148
140
|
],
|
|
149
141
|
env: envVars,
|
|
@@ -156,6 +148,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
156
148
|
resources,
|
|
157
149
|
volumeMounts: [
|
|
158
150
|
{ name: "app", mountPath: APP_DIR },
|
|
151
|
+
{ name: "telo-cache", mountPath: DEPS_DIR },
|
|
159
152
|
{ name: "work", mountPath: WORK_DIR },
|
|
160
153
|
{ name: "home", mountPath: HOME_DIR },
|
|
161
154
|
{ name: "tmp", mountPath: TMP_MOUNT },
|
|
@@ -165,6 +158,7 @@ export function buildSessionPod(args: BuildPodArgs): V1Pod {
|
|
|
165
158
|
],
|
|
166
159
|
volumes: [
|
|
167
160
|
{ name: "app", emptyDir: {} },
|
|
161
|
+
{ name: "telo-cache", emptyDir: {} },
|
|
168
162
|
{ name: "work", emptyDir: {} },
|
|
169
163
|
{ name: "home", emptyDir: {} },
|
|
170
164
|
{ name: "tmp", emptyDir: {} },
|
|
@@ -225,9 +219,7 @@ export function buildAppPod(args: BuildAppPodArgs): V1Pod {
|
|
|
225
219
|
restartPolicy: "Never",
|
|
226
220
|
activeDeadlineSeconds: limits.ttlSeconds,
|
|
227
221
|
automountServiceAccountToken: false,
|
|
228
|
-
...(config.
|
|
229
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
230
|
-
: {}),
|
|
222
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
231
223
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
232
224
|
securityContext: {
|
|
233
225
|
seccompProfile: { type: "RuntimeDefault" },
|
|
@@ -326,9 +318,8 @@ export interface BuildWatchPodArgs {
|
|
|
326
318
|
agent?: ResolvedRunnerApp;
|
|
327
319
|
limits: ResolvedLimits;
|
|
328
320
|
/** Base image every app container and the workspace container run — the plain
|
|
329
|
-
* kernel image (`telorun/node`).
|
|
330
|
-
*
|
|
331
|
-
* fetches its own and keeps it for the pod's life. */
|
|
321
|
+
* kernel image (`telorun/node`). Each resolves its own module closure into
|
|
322
|
+
* the shared workspace volume, which lives as long as the pod. */
|
|
332
323
|
image: string;
|
|
333
324
|
pullPolicy: PullPolicy;
|
|
334
325
|
/** Name of the ConfigMap holding the workspace application's manifest. */
|
|
@@ -390,9 +381,7 @@ export function buildWatchPod(args: BuildWatchPodArgs): V1Pod {
|
|
|
390
381
|
// conversation mid-turn.
|
|
391
382
|
activeDeadlineSeconds: config.watch.maxTtlSeconds,
|
|
392
383
|
automountServiceAccountToken: false,
|
|
393
|
-
...(config.
|
|
394
|
-
? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
|
|
395
|
-
: {}),
|
|
384
|
+
...(config.imagePullSecret ? { imagePullSecrets: [{ name: config.imagePullSecret }] } : {}),
|
|
396
385
|
...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
|
|
397
386
|
securityContext: {
|
|
398
387
|
runAsNonRoot: true,
|
package/src/k8s/pod-status.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { V1ContainerState, V1ContainerStatus, V1Pod } from "@kubernetes/client-node";
|
|
2
2
|
import type { RunStatus } from "@telorun/runner-core";
|
|
3
3
|
|
|
4
|
+
import { statusCode } from "./api-error.js";
|
|
4
5
|
import type { KubeClient } from "./client.js";
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -128,8 +129,7 @@ export async function deletePod(kube: KubeClient, ns: string, name: string): Pro
|
|
|
128
129
|
}
|
|
129
130
|
|
|
130
131
|
export function is404(err: unknown): boolean {
|
|
131
|
-
|
|
132
|
-
return e?.statusCode === 404 || e?.code === 404 || e?.response?.statusCode === 404;
|
|
132
|
+
return statusCode(err) === 404;
|
|
133
133
|
}
|
|
134
134
|
|
|
135
135
|
export function msg(err: unknown): string {
|
package/src/k8s/watch-session.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
|
|
24
24
|
import type { K8sRunnerConfig } from "../config.js";
|
|
25
25
|
import { clampLimits } from "../limits.js";
|
|
26
|
+
import { apiFailure, apiReason } from "./api-error.js";
|
|
26
27
|
import type { KubeClient } from "./client.js";
|
|
27
28
|
import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
|
|
28
29
|
import { buildWatchPod, inspectPortFor, WORKSPACE_PORT } from "./pod-spec.js";
|
|
@@ -54,11 +55,10 @@ export interface WatchSessionDeps {
|
|
|
54
55
|
* A watch session: one pod, one workspace volume, one container per running
|
|
55
56
|
* application, and the session outliving every run inside it.
|
|
56
57
|
*
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* later reload resolves from local disk.
|
|
58
|
+
* Its cache is what separates it from a run session: the module closure lands in
|
|
59
|
+
* a directory that lives as long as the POD rather than as long as one run, so
|
|
60
|
+
* the download happens once per session and every later reload resolves from
|
|
61
|
+
* local disk.
|
|
62
62
|
*/
|
|
63
63
|
export async function startWatchSession(
|
|
64
64
|
deps: WatchSessionDeps,
|
|
@@ -117,12 +117,7 @@ export async function startWatchSession(
|
|
|
117
117
|
try {
|
|
118
118
|
created = await kube.core.createNamespacedPod({ namespace: ns, body: pod });
|
|
119
119
|
} catch (err) {
|
|
120
|
-
throw
|
|
121
|
-
"start_failed",
|
|
122
|
-
"create",
|
|
123
|
-
`failed to create pod: ${msg(err)}`,
|
|
124
|
-
msg(err),
|
|
125
|
-
);
|
|
120
|
+
throw apiFailure(err, "create", "could not create the session pod");
|
|
126
121
|
}
|
|
127
122
|
|
|
128
123
|
const abort = new AbortController();
|
|
@@ -299,7 +294,7 @@ export async function startWatchSession(
|
|
|
299
294
|
// channel rather than aborting the start.
|
|
300
295
|
spec.onOutput(
|
|
301
296
|
app.name,
|
|
302
|
-
Buffer.from(`\r\n[runner] failed to attach: ${
|
|
297
|
+
Buffer.from(`\r\n[runner] failed to attach: ${apiReason(err)}\r\n`),
|
|
303
298
|
tagFor(app, "stderr"),
|
|
304
299
|
);
|
|
305
300
|
}
|
|
@@ -364,7 +359,7 @@ export async function startWatchSession(
|
|
|
364
359
|
await publishEndpoints(rt, apps);
|
|
365
360
|
} catch (err) {
|
|
366
361
|
spec.onEndpoints(appName, {
|
|
367
|
-
rejected: accepted.map((p) => ({ port: p.port, reason:
|
|
362
|
+
rejected: accepted.map((p) => ({ port: p.port, reason: apiReason(err) })),
|
|
368
363
|
});
|
|
369
364
|
return;
|
|
370
365
|
}
|
package/src/server.ts
CHANGED
|
@@ -54,7 +54,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
54
54
|
agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
|
|
55
55
|
}),
|
|
56
56
|
// Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
|
|
57
|
-
// /v1/capabilities; app sessions run the catalog image directly
|
|
57
|
+
// /v1/capabilities; app sessions run the catalog image directly.
|
|
58
58
|
apps,
|
|
59
59
|
validateConfig: catalog
|
|
60
60
|
? (sessionConfig: SessionConfig): string | undefined =>
|
|
@@ -64,8 +64,8 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
64
64
|
`Allowed images: ${catalog.current().join(", ")}`
|
|
65
65
|
: undefined,
|
|
66
66
|
});
|
|
67
|
-
// Mount the internal, tokenized fetch route on the same app so a
|
|
68
|
-
// initContainer can pull the
|
|
67
|
+
// Mount the internal, tokenized fetch route on the same app so a session pod's
|
|
68
|
+
// initContainer can pull the bundle tarball.
|
|
69
69
|
deps.bundleStore.registerRoute(handle.app);
|
|
70
70
|
return handle;
|
|
71
71
|
}
|