@telorun/k8s-runner 0.10.2 → 0.12.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 +222 -1
- package/dist/capabilities.d.ts +5 -0
- package/dist/capabilities.d.ts.map +1 -1
- package/dist/capabilities.js +9 -1
- package/dist/capabilities.js.map +1 -1
- package/dist/k8s/backend.d.ts +1 -8
- package/dist/k8s/backend.d.ts.map +1 -1
- package/dist/k8s/backend.js +40 -152
- package/dist/k8s/backend.js.map +1 -1
- package/dist/k8s/pod-spec.d.ts +56 -1
- package/dist/k8s/pod-spec.d.ts.map +1 -1
- package/dist/k8s/pod-spec.js +281 -0
- package/dist/k8s/pod-spec.js.map +1 -1
- package/dist/k8s/pod-status.d.ts +28 -0
- package/dist/k8s/pod-status.d.ts.map +1 -0
- package/dist/k8s/pod-status.js +137 -0
- package/dist/k8s/pod-status.js.map +1 -0
- package/dist/k8s/watch-session.d.ts +19 -0
- package/dist/k8s/watch-session.d.ts.map +1 -0
- package/dist/k8s/watch-session.js +596 -0
- package/dist/k8s/watch-session.js.map +1 -0
- package/dist/k8s/workspace-configmap.d.ts +24 -0
- package/dist/k8s/workspace-configmap.d.ts.map +1 -0
- package/dist/k8s/workspace-configmap.js +124 -0
- package/dist/k8s/workspace-configmap.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -2
- package/dist/server.js.map +1 -1
- package/package.json +2 -2
- package/src/capabilities.ts +14 -1
- package/src/k8s/backend.ts +54 -156
- package/src/k8s/pod-spec.ts +322 -2
- package/src/k8s/pod-status.ts +143 -0
- package/src/k8s/watch-pod-spec.test.ts +215 -0
- package/src/k8s/watch-session.ts +694 -0
- package/src/k8s/workspace-configmap.ts +133 -0
- package/src/server.ts +12 -1
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { K8sRunnerConfig } from "../config.js";
|
|
2
|
+
import type { KubeClient } from "./client.js";
|
|
3
|
+
export declare function workspaceConfigMapName(): string;
|
|
4
|
+
/** Create the ConfigMap if it is not already there. Content-addressed, so an
|
|
5
|
+
* existing one with this name already holds exactly these bytes and a 409 is
|
|
6
|
+
* success rather than a conflict to resolve. */
|
|
7
|
+
export declare function ensureWorkspaceConfigMap(kube: KubeClient, config: K8sRunnerConfig): Promise<string>;
|
|
8
|
+
/**
|
|
9
|
+
* Delete workspace-app ConfigMaps no live pod mounts.
|
|
10
|
+
*
|
|
11
|
+
* The name is content-addressed, so every release that changes the manifest
|
|
12
|
+
* leaves the previous one behind — permanently, since nothing else removes it.
|
|
13
|
+
* Run at boot, alongside the orphan pod reap: a session that survives a runner
|
|
14
|
+
* restart is impossible (the registry is in memory), so anything a live pod
|
|
15
|
+
* still references is a pod this process is about to reap anyway.
|
|
16
|
+
*
|
|
17
|
+
* Keeps the CURRENT hash unconditionally, and skips any map a pod still lists —
|
|
18
|
+
* a mounted ConfigMap that disappears makes the kubelet fail the pod.
|
|
19
|
+
*/
|
|
20
|
+
export declare function sweepWorkspaceConfigMaps(kube: KubeClient, config: K8sRunnerConfig, log: {
|
|
21
|
+
info(obj: unknown, msg: string): void;
|
|
22
|
+
warn(obj: unknown, msg: string): void;
|
|
23
|
+
}): Promise<void>;
|
|
24
|
+
//# sourceMappingURL=workspace-configmap.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-configmap.d.ts","sourceRoot":"","sources":["../../src/k8s/workspace-configmap.ts"],"names":[],"mappings":"AAIA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,cAAc,CAAC;AACpD,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,aAAa,CAAC;AAmB9C,wBAAgB,sBAAsB,IAAI,MAAM,CAG/C;AAED;;iDAEiD;AACjD,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,eAAe,GACtB,OAAO,CAAC,MAAM,CAAC,CA6BjB;AAeD;;;;;;;;;;;GAWG;AACH,wBAAsB,wBAAwB,CAC5C,IAAI,EAAE,UAAU,EAChB,MAAM,EAAE,eAAe,EACvB,GAAG,EAAE;IAAE,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAC;IAAC,IAAI,CAAC,GAAG,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,GAAG,IAAI,CAAA;CAAE,GACpF,OAAO,CAAC,IAAI,CAAC,CAqCf"}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "@telorun/runner-core";
|
|
3
|
+
/**
|
|
4
|
+
* The workspace application's manifest, delivered to the pod as a ConfigMap.
|
|
5
|
+
*
|
|
6
|
+
* Named by a hash of its own content, so a runner upgrade that changes the
|
|
7
|
+
* manifest creates a NEW ConfigMap and leaves running sessions mounting the one
|
|
8
|
+
* they booted with — a mutable name would swap a manifest under a live pod, and
|
|
9
|
+
* kubelet ConfigMap propagation is asynchronous, so the failure would be a
|
|
10
|
+
* workspace container that restarts into a different surface at an arbitrary
|
|
11
|
+
* moment.
|
|
12
|
+
*
|
|
13
|
+
* Reconciled by the runner rather than shipped by the chart, so the manifest and
|
|
14
|
+
* the code that depends on it version as one artifact: a chart-only upgrade
|
|
15
|
+
* cannot leave them skewed. The cost is one RBAC rule (`configmaps: get, create`
|
|
16
|
+
* in the session namespace).
|
|
17
|
+
*/
|
|
18
|
+
const WORKSPACE_CONFIGMAP_PREFIX = "telo-workspace-app-";
|
|
19
|
+
export function workspaceConfigMapName() {
|
|
20
|
+
const digest = createHash("sha256").update(workspaceAppManifest()).digest("hex").slice(0, 12);
|
|
21
|
+
return `${WORKSPACE_CONFIGMAP_PREFIX}${digest}`;
|
|
22
|
+
}
|
|
23
|
+
/** Create the ConfigMap if it is not already there. Content-addressed, so an
|
|
24
|
+
* existing one with this name already holds exactly these bytes and a 409 is
|
|
25
|
+
* success rather than a conflict to resolve. */
|
|
26
|
+
export async function ensureWorkspaceConfigMap(kube, config) {
|
|
27
|
+
const name = workspaceConfigMapName();
|
|
28
|
+
try {
|
|
29
|
+
await kube.core.readNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
30
|
+
return name;
|
|
31
|
+
}
|
|
32
|
+
catch (err) {
|
|
33
|
+
if (!isNotFound(err))
|
|
34
|
+
throw err;
|
|
35
|
+
}
|
|
36
|
+
try {
|
|
37
|
+
await kube.core.createNamespacedConfigMap({
|
|
38
|
+
namespace: config.sessionNamespace,
|
|
39
|
+
body: {
|
|
40
|
+
apiVersion: "v1",
|
|
41
|
+
kind: "ConfigMap",
|
|
42
|
+
metadata: {
|
|
43
|
+
name,
|
|
44
|
+
namespace: config.sessionNamespace,
|
|
45
|
+
labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
|
|
46
|
+
},
|
|
47
|
+
data: { [WORKSPACE_APP_FILENAME]: workspaceAppManifest() },
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
catch (err) {
|
|
52
|
+
// Two runners (or two concurrent starts) racing to create the same
|
|
53
|
+
// content-addressed name is not a failure — the loser mounts the winner's
|
|
54
|
+
// identical bytes.
|
|
55
|
+
if (!isConflict(err))
|
|
56
|
+
throw err;
|
|
57
|
+
}
|
|
58
|
+
return name;
|
|
59
|
+
}
|
|
60
|
+
function statusOf(err) {
|
|
61
|
+
const e = err;
|
|
62
|
+
return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
|
|
63
|
+
}
|
|
64
|
+
function isNotFound(err) {
|
|
65
|
+
return statusOf(err) === 404;
|
|
66
|
+
}
|
|
67
|
+
function isConflict(err) {
|
|
68
|
+
return statusOf(err) === 409;
|
|
69
|
+
}
|
|
70
|
+
/**
|
|
71
|
+
* Delete workspace-app ConfigMaps no live pod mounts.
|
|
72
|
+
*
|
|
73
|
+
* The name is content-addressed, so every release that changes the manifest
|
|
74
|
+
* leaves the previous one behind — permanently, since nothing else removes it.
|
|
75
|
+
* Run at boot, alongside the orphan pod reap: a session that survives a runner
|
|
76
|
+
* restart is impossible (the registry is in memory), so anything a live pod
|
|
77
|
+
* still references is a pod this process is about to reap anyway.
|
|
78
|
+
*
|
|
79
|
+
* Keeps the CURRENT hash unconditionally, and skips any map a pod still lists —
|
|
80
|
+
* a mounted ConfigMap that disappears makes the kubelet fail the pod.
|
|
81
|
+
*/
|
|
82
|
+
export async function sweepWorkspaceConfigMaps(kube, config, log) {
|
|
83
|
+
const keep = new Set([workspaceConfigMapName()]);
|
|
84
|
+
let pods;
|
|
85
|
+
let maps;
|
|
86
|
+
try {
|
|
87
|
+
[pods, maps] = await Promise.all([
|
|
88
|
+
kube.core.listNamespacedPod({ namespace: config.sessionNamespace }),
|
|
89
|
+
kube.core.listNamespacedConfigMap({
|
|
90
|
+
namespace: config.sessionNamespace,
|
|
91
|
+
labelSelector: `app.kubernetes.io/managed-by=${config.managedByLabel}`,
|
|
92
|
+
}),
|
|
93
|
+
]);
|
|
94
|
+
}
|
|
95
|
+
catch (err) {
|
|
96
|
+
// Housekeeping, not correctness — a runner that cannot list is a runner
|
|
97
|
+
// whose next boot will try again.
|
|
98
|
+
log.warn({ err }, "could not sweep workspace-app ConfigMaps");
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
for (const pod of pods.items ?? []) {
|
|
102
|
+
for (const volume of pod.spec?.volumes ?? []) {
|
|
103
|
+
if (volume.configMap?.name)
|
|
104
|
+
keep.add(volume.configMap.name);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
let removed = 0;
|
|
108
|
+
for (const map of maps.items ?? []) {
|
|
109
|
+
const name = map.metadata?.name;
|
|
110
|
+
if (!name?.startsWith(WORKSPACE_CONFIGMAP_PREFIX) || keep.has(name))
|
|
111
|
+
continue;
|
|
112
|
+
try {
|
|
113
|
+
await kube.core.deleteNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
114
|
+
removed += 1;
|
|
115
|
+
}
|
|
116
|
+
catch (err) {
|
|
117
|
+
if (!isNotFound(err))
|
|
118
|
+
log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
if (removed > 0)
|
|
122
|
+
log.info({ removed }, "swept stale workspace-app ConfigMaps");
|
|
123
|
+
}
|
|
124
|
+
//# sourceMappingURL=workspace-configmap.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"workspace-configmap.js","sourceRoot":"","sources":["../../src/k8s/workspace-configmap.ts"],"names":[],"mappings":"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"}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAMhB,KAAK,aAAa,EAClB,KAAK,YAAY,EAElB,MAAM,sBAAsB,CAAC;AAG9B,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAEhD,OAAO,EAA0C,KAAK,eAAe,EAAE,MAAM,aAAa,CAAC;AAO3F,MAAM,WAAW,UAAU;IACzB,OAAO,EAAE,aAAa,CAAC;IACvB,MAAM,EAAE,eAAe,CAAC;IACxB,WAAW,EAAE,WAAW,CAAC;IACzB;qFACiF;IACjF,OAAO,CAAC,EAAE,gBAAgB,CAAC;CAC5B;AAED,wBAAsB,WAAW,CAAC,IAAI,EAAE,UAAU,GAAG,OAAO,CAAC,YAAY,CAAC,CAwCzE"}
|
package/dist/server.js
CHANGED
|
@@ -1,16 +1,18 @@
|
|
|
1
|
-
import { BaseImageCatalog, buildServer as coreBuildServer, loadResolvedApps, loadTermsFromEnv, stopAllSessions, } from "@telorun/runner-core";
|
|
1
|
+
import { BaseImageCatalog, buildServer as coreBuildServer, coResidentAgentNames, loadResolvedApps, loadTermsFromEnv, stopAllSessions, } from "@telorun/runner-core";
|
|
2
2
|
import packageJson from "../package.json" with { type: "json" };
|
|
3
3
|
import { BundleStore } from "./bundle-store.js";
|
|
4
4
|
import { kubernetesRunnerCapabilities } from "./capabilities.js";
|
|
5
5
|
import { loadK8sRunnerConfig, RunnerConfigError } from "./config.js";
|
|
6
6
|
import { createKubernetesBackend } from "./k8s/backend.js";
|
|
7
7
|
import { createKubeClient } from "./k8s/client.js";
|
|
8
|
+
import { sweepWorkspaceConfigMaps } from "./k8s/workspace-configmap.js";
|
|
8
9
|
const VERSION = packageJson.version;
|
|
9
10
|
export async function buildServer(deps) {
|
|
10
11
|
const { catalog } = deps;
|
|
11
12
|
// Load terms once; the capabilities getter is re-resolved per request so a
|
|
12
13
|
// refreshed catalog (new tags) shows up without restarting the runner.
|
|
13
14
|
const terms = loadTermsFromEnv(process.env);
|
|
15
|
+
const apps = loadResolvedApps(process.env);
|
|
14
16
|
const handle = await coreBuildServer({
|
|
15
17
|
backend: deps.backend,
|
|
16
18
|
config: deps.config,
|
|
@@ -21,11 +23,16 @@ export async function buildServer(deps) {
|
|
|
21
23
|
defaultImage: deps.config.defaultImage,
|
|
22
24
|
terms,
|
|
23
25
|
imageEnum: catalog?.current(),
|
|
26
|
+
watch: deps.config.watch.enabled,
|
|
27
|
+
// Only entries that declare a port: the session route refuses an agent
|
|
28
|
+
// without one, and advertising what will be rejected is what makes the
|
|
29
|
+
// editor attach an agent to every run and every run fail.
|
|
30
|
+
agents: deps.config.watch.enabled ? coResidentAgentNames(apps) : undefined,
|
|
24
31
|
}),
|
|
25
32
|
defaultRegistryUrl: process.env.TELO_REGISTRY_URL,
|
|
26
33
|
// Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
|
|
27
34
|
// /v1/capabilities; app sessions run the catalog image directly (no build).
|
|
28
|
-
apps
|
|
35
|
+
apps,
|
|
29
36
|
validateConfig: catalog
|
|
30
37
|
? (sessionConfig) => catalog.isAllowed(sessionConfig.image)
|
|
31
38
|
? undefined
|
|
@@ -75,6 +82,9 @@ async function main() {
|
|
|
75
82
|
// Reap pods orphaned by a prior runner process (in-memory registry).
|
|
76
83
|
if (backend.reapOrphans) {
|
|
77
84
|
await backend.reapOrphans().catch((err) => app.log.warn({ err }, "orphan reap failed"));
|
|
85
|
+
// After the pods are gone: a content-addressed workspace ConfigMap outlives
|
|
86
|
+
// every release that changed the manifest, and nothing else removes it.
|
|
87
|
+
await sweepWorkspaceConfigMaps(kube, config, app.log);
|
|
78
88
|
}
|
|
79
89
|
try {
|
|
80
90
|
await app.listen({ port: config.port, host: "0.0.0.0" });
|
package/dist/server.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,gBAAgB,EAChB,WAAW,IAAI,eAAe,EAC9B,gBAAgB,EAChB,gBAAgB,EAChB,eAAe,GAIhB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,WAAW,MAAM,iBAAiB,CAAC,OAAO,IAAI,EAAE,MAAM,EAAE,CAAC;AAChE,OAAO,EAAE,WAAW,EAAE,MAAM,mBAAmB,CAAC;AAChD,OAAO,EAAE,4BAA4B,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,EAAE,mBAAmB,EAAE,iBAAiB,EAAwB,MAAM,aAAa,CAAC;AAC3F,OAAO,EAAE,uBAAuB,EAAE,MAAM,kBAAkB,CAAC;AAC3D,OAAO,EAAE,gBAAgB,EAAE,MAAM,iBAAiB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@telorun/k8s-runner",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.12.0",
|
|
4
4
|
"description": "Telo Kubernetes Runner - HTTP service that runs Telo Applications as sandboxed Kubernetes Pods. A backend over @telorun/runner-core, sibling to docker-runner.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"telo",
|
|
@@ -23,7 +23,7 @@
|
|
|
23
23
|
"dependencies": {
|
|
24
24
|
"@kubernetes/client-node": "^1.4.0",
|
|
25
25
|
"fastify": "^5.12.1",
|
|
26
|
-
"@telorun/runner-core": "0.
|
|
26
|
+
"@telorun/runner-core": "0.10.0"
|
|
27
27
|
},
|
|
28
28
|
"devDependencies": {
|
|
29
29
|
"@types/node": "^20.0.0",
|
package/src/capabilities.ts
CHANGED
|
@@ -12,6 +12,11 @@ export interface KubernetesRunnerCapabilitiesOptions {
|
|
|
12
12
|
defaultImage: string;
|
|
13
13
|
terms?: RunnerTerms;
|
|
14
14
|
imageEnum?: string[];
|
|
15
|
+
/** Whether the operator enabled watch sessions. Advertised so a client knows
|
|
16
|
+
* before it asks — a runner with watch off rejects the field. */
|
|
17
|
+
watch: boolean;
|
|
18
|
+
/** Catalog names admissible as a session's co-resident `agent`. */
|
|
19
|
+
agents?: string[];
|
|
15
20
|
}
|
|
16
21
|
|
|
17
22
|
/** What k8s-runner advertises on `/v1/capabilities`. The runner serves
|
|
@@ -33,7 +38,15 @@ export function kubernetesRunnerCapabilities(
|
|
|
33
38
|
return {
|
|
34
39
|
displayName,
|
|
35
40
|
description,
|
|
36
|
-
features: {
|
|
41
|
+
features: {
|
|
42
|
+
// Both attach modes: the kubernetes attach subresource without a TTY
|
|
43
|
+
// already gives separate stdout and stderr channels, so `streams` invents
|
|
44
|
+
// nothing at the transport layer.
|
|
45
|
+
io: ["tty", "streams"],
|
|
46
|
+
ports: true,
|
|
47
|
+
watch: opts.watch,
|
|
48
|
+
...(opts.agents && opts.agents.length > 0 ? { agents: opts.agents } : {}),
|
|
49
|
+
},
|
|
37
50
|
config: {
|
|
38
51
|
schema: sessionConfigSchema({
|
|
39
52
|
imageDefault: defaultImage,
|
package/src/k8s/backend.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { PassThrough, Writable } from "node:stream";
|
|
2
2
|
|
|
3
|
-
import type {
|
|
3
|
+
import type { V1Pod } from "@kubernetes/client-node";
|
|
4
4
|
import type {
|
|
5
5
|
AvailabilityReport,
|
|
6
6
|
BackendSession,
|
|
7
7
|
BackendStartSpec,
|
|
8
|
+
PortMapping,
|
|
8
9
|
ProbeConfig,
|
|
9
10
|
RunStatus,
|
|
10
11
|
RunnerBackend,
|
|
@@ -18,6 +19,19 @@ import type { KubeClient } from "./client.js";
|
|
|
18
19
|
import { ensureSessionImage } from "./image-build.js";
|
|
19
20
|
import { buildSessionIngress, buildSessionService, endpointsFor } from "./ingress.js";
|
|
20
21
|
import { buildAppPod, buildSessionPod, INSPECT_PORT } from "./pod-spec.js";
|
|
22
|
+
import {
|
|
23
|
+
deletePod,
|
|
24
|
+
is404,
|
|
25
|
+
msg,
|
|
26
|
+
podFailureMessage,
|
|
27
|
+
podStatus,
|
|
28
|
+
podPhase,
|
|
29
|
+
provisionMessage,
|
|
30
|
+
terminalStatus,
|
|
31
|
+
} from "./pod-status.js";
|
|
32
|
+
import { startWatchSession } from "./watch-session.js";
|
|
33
|
+
|
|
34
|
+
export { podFailureMessage } from "./pod-status.js";
|
|
21
35
|
|
|
22
36
|
/** Minimal surface of the websocket client-node's Attach returns. */
|
|
23
37
|
interface ResizableSocket {
|
|
@@ -42,7 +56,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
42
56
|
const { kube, config, bundleStore } = deps;
|
|
43
57
|
const ns = config.sessionNamespace;
|
|
44
58
|
|
|
45
|
-
async function probe(
|
|
59
|
+
async function probe(probeConfig: ProbeConfig): Promise<AvailabilityReport> {
|
|
46
60
|
try {
|
|
47
61
|
await kube.core.readNamespace({ name: ns });
|
|
48
62
|
} catch {
|
|
@@ -64,7 +78,23 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
64
78
|
}
|
|
65
79
|
|
|
66
80
|
async function start(spec: BackendStartSpec): Promise<BackendSession> {
|
|
81
|
+
// A watch session is a different pod shape and a different lifetime — it
|
|
82
|
+
// outlives its runs — so it takes its own path rather than accreting
|
|
83
|
+
// branches through this one. It also never builds an image: the build path
|
|
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.
|
|
86
|
+
if (spec.mode === "watch") {
|
|
87
|
+
return startWatchSession({ kube, config }, spec);
|
|
88
|
+
}
|
|
89
|
+
return startRunSession(spec);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function startRunSession(spec: BackendStartSpec): Promise<BackendSession> {
|
|
67
93
|
const podName = `telo-run-${spec.sessionId}`;
|
|
94
|
+
// A run session is one application by construction — `apps` carries exactly
|
|
95
|
+
// one entry, defaulted by core when the request declared none.
|
|
96
|
+
const app = spec.apps[0]!;
|
|
97
|
+
const appName = app.name;
|
|
68
98
|
// The /v1 contract carries no per-request limits yet, so `requested` is
|
|
69
99
|
// undefined and the configured ceiling is always the effective limit. When
|
|
70
100
|
// a control plane begins passing limits, plumb them here — the clamp is
|
|
@@ -82,7 +112,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
82
112
|
sessionId: spec.sessionId,
|
|
83
113
|
podName,
|
|
84
114
|
env: spec.env,
|
|
85
|
-
ports:
|
|
115
|
+
ports: app.ports,
|
|
86
116
|
limits,
|
|
87
117
|
image: spec.config.image,
|
|
88
118
|
pullPolicy: spec.config.pullPolicy,
|
|
@@ -103,10 +133,10 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
103
133
|
},
|
|
104
134
|
{
|
|
105
135
|
bundle: spec.bundle,
|
|
106
|
-
entryRelativePath:
|
|
136
|
+
entryRelativePath: app.entryRelativePath,
|
|
107
137
|
baseImage: spec.config.image || config.defaultImage,
|
|
108
138
|
pullPolicy: spec.config.pullPolicy,
|
|
109
|
-
onProgress: (message, done) => spec.onProgress("build", message, done),
|
|
139
|
+
onProgress: (message, done) => spec.onProgress("build", message, done, appName),
|
|
110
140
|
},
|
|
111
141
|
);
|
|
112
142
|
|
|
@@ -118,9 +148,9 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
118
148
|
config,
|
|
119
149
|
sessionId: spec.sessionId,
|
|
120
150
|
podName,
|
|
121
|
-
entryRelativePath:
|
|
151
|
+
entryRelativePath: app.entryRelativePath,
|
|
122
152
|
env: spec.env,
|
|
123
|
-
ports:
|
|
153
|
+
ports: app.ports,
|
|
124
154
|
limits,
|
|
125
155
|
image,
|
|
126
156
|
bundleUrl,
|
|
@@ -151,8 +181,8 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
151
181
|
|
|
152
182
|
const stdin = new PassThrough();
|
|
153
183
|
const stdout = new Writable({
|
|
154
|
-
write(chunk: Buffer,
|
|
155
|
-
if (chunk?.byteLength) spec.onOutput(Buffer.from(chunk));
|
|
184
|
+
write(chunk: Buffer, encoding, cb) {
|
|
185
|
+
if (chunk?.byteLength) spec.onOutput(appName, Buffer.from(chunk), "tty");
|
|
156
186
|
cb();
|
|
157
187
|
},
|
|
158
188
|
});
|
|
@@ -189,7 +219,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
189
219
|
const flipRunning = (): void => {
|
|
190
220
|
if (readyFlipped || finished) return;
|
|
191
221
|
readyFlipped = true;
|
|
192
|
-
spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId,
|
|
222
|
+
spec.onStatus({ kind: "running", endpoints: endpointsFor(config, spec.sessionId, app.ports) });
|
|
193
223
|
};
|
|
194
224
|
|
|
195
225
|
let resolveRunning!: () => void;
|
|
@@ -207,7 +237,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
207
237
|
const provision = provisionMessage(obj);
|
|
208
238
|
if (provision && provision !== lastProvision) {
|
|
209
239
|
lastProvision = provision;
|
|
210
|
-
spec.onProgress("provision", provision);
|
|
240
|
+
spec.onProgress("provision", provision, undefined, appName);
|
|
211
241
|
}
|
|
212
242
|
}
|
|
213
243
|
if (phase === "Running" && !runningSeen) {
|
|
@@ -253,7 +283,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
253
283
|
const req = await kube.watch.watch(
|
|
254
284
|
`/api/v1/namespaces/${ns}/pods`,
|
|
255
285
|
{ fieldSelector: `metadata.name=${podName}` },
|
|
256
|
-
(
|
|
286
|
+
(type: string, obj: unknown) => handlePhase(obj),
|
|
257
287
|
() => {
|
|
258
288
|
if (finished) return;
|
|
259
289
|
void reconcileOnce().then(() => {
|
|
@@ -303,12 +333,12 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
303
333
|
socket = ws as unknown as ResizableSocket;
|
|
304
334
|
} catch (err) {
|
|
305
335
|
// Attach failure isn't fatal — status still flows; surface the degraded PTY.
|
|
306
|
-
spec.onOutput(Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`));
|
|
336
|
+
spec.onOutput(appName, Buffer.from(`\r\n[runner] failed to attach PTY: ${msg(err)}\r\n`), "tty");
|
|
307
337
|
}
|
|
308
338
|
|
|
309
|
-
if (config.sessionIngressBaseDomain &&
|
|
310
|
-
await createIngress(deps, spec.sessionId, podName, podUid,
|
|
311
|
-
spec.onOutput(Buffer.from(`\r\n[runner] failed to create ingress: ${msg(err)}\r\n`));
|
|
339
|
+
if (config.sessionIngressBaseDomain && app.ports.length > 0) {
|
|
340
|
+
await createIngress(deps, spec.sessionId, podName, podUid, app.ports).catch((err) => {
|
|
341
|
+
spec.onOutput(appName, Buffer.from(`\r\n[runner] failed to create ingress: ${msg(err)}\r\n`), "tty");
|
|
312
342
|
});
|
|
313
343
|
}
|
|
314
344
|
|
|
@@ -331,11 +361,11 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
331
361
|
if (ip) {
|
|
332
362
|
void relayDebugStream({
|
|
333
363
|
url: `http://${ip}:${INSPECT_PORT}/events`,
|
|
334
|
-
onFrame: spec.onDebug,
|
|
364
|
+
onFrame: (frame) => spec.onDebug(appName, frame),
|
|
335
365
|
signal: debugAbort.signal,
|
|
336
366
|
});
|
|
337
367
|
} else {
|
|
338
|
-
spec.onOutput(Buffer.from("\r\n[runner] debug stream unavailable: pod IP unknown\r\n"));
|
|
368
|
+
spec.onOutput(appName, Buffer.from("\r\n[runner] debug stream unavailable: pod IP unknown\r\n"), "tty");
|
|
339
369
|
}
|
|
340
370
|
}
|
|
341
371
|
|
|
@@ -343,7 +373,7 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
343
373
|
// wrong port) is unreachable on the pod network and surfaces only as a
|
|
344
374
|
// downstream 502. Watch each advertised tcp port from the runner and report
|
|
345
375
|
// per-port state to studio's endpoint badge. Background; finish() aborts it.
|
|
346
|
-
const tcpPorts =
|
|
376
|
+
const tcpPorts = app.ports.filter((p) => p.protocol === "tcp").map((p) => p.port);
|
|
347
377
|
if (tcpPorts.length > 0) {
|
|
348
378
|
void (async () => {
|
|
349
379
|
const ip = await resolvePodIP();
|
|
@@ -351,13 +381,13 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
351
381
|
if (!ip) {
|
|
352
382
|
// Couldn't resolve the pod IP to probe — report unverified rather than
|
|
353
383
|
// leaving the badge spinning forever.
|
|
354
|
-
for (const port of tcpPorts) spec.onReachability(port, "unreachable");
|
|
384
|
+
for (const port of tcpPorts) spec.onReachability(appName, port, "unreachable");
|
|
355
385
|
return;
|
|
356
386
|
}
|
|
357
387
|
await watchReachability({
|
|
358
388
|
host: ip,
|
|
359
389
|
ports: tcpPorts,
|
|
360
|
-
onState: (port, state) => spec.onReachability(port, state),
|
|
390
|
+
onState: (port, state) => spec.onReachability(appName, port, state),
|
|
361
391
|
signal: reachAbort.signal,
|
|
362
392
|
});
|
|
363
393
|
})();
|
|
@@ -366,14 +396,14 @@ export function createKubernetesBackend(deps: K8sBackendDeps): RunnerBackend {
|
|
|
366
396
|
// `flipRunning` already announced `running` from the watch's Running
|
|
367
397
|
// transition (which `await running` above waited on).
|
|
368
398
|
return {
|
|
369
|
-
writeStdin(bytes) {
|
|
399
|
+
writeStdin(app, bytes) {
|
|
370
400
|
try {
|
|
371
401
|
stdin.write(Buffer.from(bytes));
|
|
372
402
|
} catch {
|
|
373
403
|
/* stream ended */
|
|
374
404
|
}
|
|
375
405
|
},
|
|
376
|
-
resize(cols, rows) {
|
|
406
|
+
resize(app, cols, rows) {
|
|
377
407
|
if (!socket) return;
|
|
378
408
|
try {
|
|
379
409
|
const payload = Buffer.from(JSON.stringify({ Width: cols, Height: rows }));
|
|
@@ -423,7 +453,7 @@ async function createIngress(
|
|
|
423
453
|
sessionId: string,
|
|
424
454
|
podName: string,
|
|
425
455
|
podUid: string,
|
|
426
|
-
ports:
|
|
456
|
+
ports: PortMapping[],
|
|
427
457
|
): Promise<void> {
|
|
428
458
|
const { kube, config } = deps;
|
|
429
459
|
const ns = config.sessionNamespace;
|
|
@@ -442,15 +472,6 @@ async function createIngress(
|
|
|
442
472
|
await kube.networking.createNamespacedIngress({ namespace: ns, body: ingress });
|
|
443
473
|
}
|
|
444
474
|
|
|
445
|
-
async function deletePod(kube: KubeClient, ns: string, name: string): Promise<void> {
|
|
446
|
-
try {
|
|
447
|
-
await kube.core.deleteNamespacedPod({ name, namespace: ns, gracePeriodSeconds: 0 });
|
|
448
|
-
} catch (err) {
|
|
449
|
-
// 404 = already gone (natural exit + GC). Anything else is a real failure.
|
|
450
|
-
if (!is404(err)) throw err;
|
|
451
|
-
}
|
|
452
|
-
}
|
|
453
|
-
|
|
454
475
|
async function clusterReachable(kube: KubeClient): Promise<boolean> {
|
|
455
476
|
try {
|
|
456
477
|
await kube.core.listNamespace();
|
|
@@ -459,126 +480,3 @@ async function clusterReachable(kube: KubeClient): Promise<boolean> {
|
|
|
459
480
|
return false;
|
|
460
481
|
}
|
|
461
482
|
}
|
|
462
|
-
|
|
463
|
-
function podStatus(obj: unknown): V1Pod["status"] | undefined {
|
|
464
|
-
return (obj as V1Pod | undefined)?.status;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
function podPhase(obj: unknown): string | undefined {
|
|
468
|
-
return podStatus(obj)?.phase;
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
/** A coming-up message for the studio feed while the Pod is still scheduling /
|
|
472
|
-
* pulling / delivering the body / creating the container; undefined once running. */
|
|
473
|
-
function provisionMessage(obj: unknown): string | undefined {
|
|
474
|
-
const status = podStatus(obj);
|
|
475
|
-
if (status?.phase !== "Pending") return undefined;
|
|
476
|
-
const containers = [
|
|
477
|
-
...(status.initContainerStatuses ?? []),
|
|
478
|
-
...(status.containerStatuses ?? []),
|
|
479
|
-
];
|
|
480
|
-
for (const cs of containers) {
|
|
481
|
-
const reason = cs.state?.waiting?.reason;
|
|
482
|
-
if (reason) return humanizeWaitReason(reason);
|
|
483
|
-
}
|
|
484
|
-
return "Scheduling";
|
|
485
|
-
}
|
|
486
|
-
|
|
487
|
-
function humanizeWaitReason(reason: string): string {
|
|
488
|
-
switch (reason) {
|
|
489
|
-
case "ContainerCreating":
|
|
490
|
-
return "Creating container";
|
|
491
|
-
case "PodInitializing":
|
|
492
|
-
return "Delivering application";
|
|
493
|
-
case "ErrImagePull":
|
|
494
|
-
case "ImagePullBackOff":
|
|
495
|
-
return "Pulling image";
|
|
496
|
-
default:
|
|
497
|
-
return reason;
|
|
498
|
-
}
|
|
499
|
-
}
|
|
500
|
-
|
|
501
|
-
function terminalStatus(obj: unknown, userStopped: boolean): RunStatus {
|
|
502
|
-
if (userStopped) return { kind: "stopped" };
|
|
503
|
-
const phase = podPhase(obj);
|
|
504
|
-
if (phase === "Succeeded") return { kind: "exited", code: containerExitCode(obj) ?? 0 };
|
|
505
|
-
return { kind: "failed", message: podFailureMessage(obj) };
|
|
506
|
-
}
|
|
507
|
-
|
|
508
|
-
// Exit code of the main session container — used to report a clean exit.
|
|
509
|
-
function containerExitCode(obj: unknown): number | null {
|
|
510
|
-
const term = podStatus(obj)?.containerStatuses?.[0]?.state?.terminated;
|
|
511
|
-
return typeof term?.exitCode === "number" ? term.exitCode : null;
|
|
512
|
-
}
|
|
513
|
-
|
|
514
|
-
const MAX_FAILURE_DETAIL = 500;
|
|
515
|
-
|
|
516
|
-
/**
|
|
517
|
-
* Builds an actionable failure message from a terminal Pod status. Init
|
|
518
|
-
* containers are inspected first: a failed init container leaves the main
|
|
519
|
-
* container unstarted, so reading only `containerStatuses` would fall through
|
|
520
|
-
* to the bare "pod failed". For prebuilt session pods the common failure is the
|
|
521
|
-
* main container itself (image pull, OOM, a non-zero exit).
|
|
522
|
-
*/
|
|
523
|
-
export function podFailureMessage(obj: unknown): string {
|
|
524
|
-
const status = podStatus(obj);
|
|
525
|
-
const fromContainer = firstContainerProblem(status);
|
|
526
|
-
if (fromContainer) return fromContainer;
|
|
527
|
-
if (status?.message) return truncateDetail(status.message);
|
|
528
|
-
if (status?.reason) return status.reason;
|
|
529
|
-
return "pod failed";
|
|
530
|
-
}
|
|
531
|
-
|
|
532
|
-
function firstContainerProblem(status: V1Pod["status"] | undefined): string | undefined {
|
|
533
|
-
const groups: Array<[string, V1ContainerStatus[] | undefined]> = [
|
|
534
|
-
["init container", status?.initContainerStatuses],
|
|
535
|
-
["container", status?.containerStatuses],
|
|
536
|
-
];
|
|
537
|
-
for (const [label, statuses] of groups) {
|
|
538
|
-
for (const cs of statuses ?? []) {
|
|
539
|
-
const problem = containerStateProblem(cs.state) ?? containerStateProblem(cs.lastState);
|
|
540
|
-
if (problem) return `${label} "${cs.name}" ${problem}`;
|
|
541
|
-
}
|
|
542
|
-
}
|
|
543
|
-
return undefined;
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
function containerStateProblem(state: V1ContainerState | undefined): string | undefined {
|
|
547
|
-
const term = state?.terminated;
|
|
548
|
-
if (term && term.exitCode !== 0) {
|
|
549
|
-
const reason = term.reason ? `${term.reason} ` : "";
|
|
550
|
-
const detail = term.message ? `: ${truncateDetail(term.message)}` : "";
|
|
551
|
-
return `failed: ${reason}(exit code ${term.exitCode ?? "unknown"})${detail}`;
|
|
552
|
-
}
|
|
553
|
-
const waiting = state?.waiting;
|
|
554
|
-
if (waiting?.reason && isBlockingWaitReason(waiting.reason)) {
|
|
555
|
-
const detail = waiting.message ? `: ${truncateDetail(waiting.message)}` : "";
|
|
556
|
-
return `waiting: ${waiting.reason}${detail}`;
|
|
557
|
-
}
|
|
558
|
-
return undefined;
|
|
559
|
-
}
|
|
560
|
-
|
|
561
|
-
// Benign transient reasons the kubelet reports while a Pod is still coming up.
|
|
562
|
-
function isBlockingWaitReason(reason: string): boolean {
|
|
563
|
-
return reason !== "PodInitializing" && reason !== "ContainerCreating";
|
|
564
|
-
}
|
|
565
|
-
|
|
566
|
-
function truncateDetail(text: string): string {
|
|
567
|
-
const trimmed = text.trim();
|
|
568
|
-
return trimmed.length > MAX_FAILURE_DETAIL ? `${trimmed.slice(0, MAX_FAILURE_DETAIL)}…` : trimmed;
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
function is404(err: unknown): boolean {
|
|
572
|
-
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
573
|
-
return e?.statusCode === 404 || e?.code === 404 || e?.response?.statusCode === 404;
|
|
574
|
-
}
|
|
575
|
-
|
|
576
|
-
function msg(err: unknown): string {
|
|
577
|
-
if (err instanceof Error) return err.message;
|
|
578
|
-
if (typeof err === "string") return err;
|
|
579
|
-
try {
|
|
580
|
-
return JSON.stringify(err);
|
|
581
|
-
} catch {
|
|
582
|
-
return String(err);
|
|
583
|
-
}
|
|
584
|
-
}
|