@telorun/k8s-runner 0.10.2 → 0.11.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 +197 -0
- 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 +275 -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 +565 -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 +11 -1
- 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 +316 -2
- package/src/k8s/pod-status.ts +143 -0
- package/src/k8s/watch-pod-spec.test.ts +195 -0
- package/src/k8s/watch-session.ts +662 -0
- package/src/k8s/workspace-configmap.ts +133 -0
- package/src/server.ts +11 -1
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
import { workspaceAppManifest, WORKSPACE_APP_FILENAME } from "@telorun/runner-core";
|
|
4
|
+
|
|
5
|
+
import type { K8sRunnerConfig } from "../config.js";
|
|
6
|
+
import type { KubeClient } from "./client.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The workspace application's manifest, delivered to the pod as a ConfigMap.
|
|
10
|
+
*
|
|
11
|
+
* Named by a hash of its own content, so a runner upgrade that changes the
|
|
12
|
+
* manifest creates a NEW ConfigMap and leaves running sessions mounting the one
|
|
13
|
+
* they booted with — a mutable name would swap a manifest under a live pod, and
|
|
14
|
+
* kubelet ConfigMap propagation is asynchronous, so the failure would be a
|
|
15
|
+
* workspace container that restarts into a different surface at an arbitrary
|
|
16
|
+
* moment.
|
|
17
|
+
*
|
|
18
|
+
* Reconciled by the runner rather than shipped by the chart, so the manifest and
|
|
19
|
+
* the code that depends on it version as one artifact: a chart-only upgrade
|
|
20
|
+
* cannot leave them skewed. The cost is one RBAC rule (`configmaps: get, create`
|
|
21
|
+
* in the session namespace).
|
|
22
|
+
*/
|
|
23
|
+
const WORKSPACE_CONFIGMAP_PREFIX = "telo-workspace-app-";
|
|
24
|
+
|
|
25
|
+
export function workspaceConfigMapName(): string {
|
|
26
|
+
const digest = createHash("sha256").update(workspaceAppManifest()).digest("hex").slice(0, 12);
|
|
27
|
+
return `${WORKSPACE_CONFIGMAP_PREFIX}${digest}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Create the ConfigMap if it is not already there. Content-addressed, so an
|
|
31
|
+
* existing one with this name already holds exactly these bytes and a 409 is
|
|
32
|
+
* success rather than a conflict to resolve. */
|
|
33
|
+
export async function ensureWorkspaceConfigMap(
|
|
34
|
+
kube: KubeClient,
|
|
35
|
+
config: K8sRunnerConfig,
|
|
36
|
+
): Promise<string> {
|
|
37
|
+
const name = workspaceConfigMapName();
|
|
38
|
+
try {
|
|
39
|
+
await kube.core.readNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
40
|
+
return name;
|
|
41
|
+
} catch (err) {
|
|
42
|
+
if (!isNotFound(err)) throw err;
|
|
43
|
+
}
|
|
44
|
+
try {
|
|
45
|
+
await kube.core.createNamespacedConfigMap({
|
|
46
|
+
namespace: config.sessionNamespace,
|
|
47
|
+
body: {
|
|
48
|
+
apiVersion: "v1",
|
|
49
|
+
kind: "ConfigMap",
|
|
50
|
+
metadata: {
|
|
51
|
+
name,
|
|
52
|
+
namespace: config.sessionNamespace,
|
|
53
|
+
labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
|
|
54
|
+
},
|
|
55
|
+
data: { [WORKSPACE_APP_FILENAME]: workspaceAppManifest() },
|
|
56
|
+
},
|
|
57
|
+
});
|
|
58
|
+
} catch (err) {
|
|
59
|
+
// Two runners (or two concurrent starts) racing to create the same
|
|
60
|
+
// content-addressed name is not a failure — the loser mounts the winner's
|
|
61
|
+
// identical bytes.
|
|
62
|
+
if (!isConflict(err)) throw err;
|
|
63
|
+
}
|
|
64
|
+
return name;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function statusOf(err: unknown): number | undefined {
|
|
68
|
+
const e = err as { statusCode?: number; code?: number; response?: { statusCode?: number } };
|
|
69
|
+
return e?.statusCode ?? e?.code ?? e?.response?.statusCode;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function isNotFound(err: unknown): boolean {
|
|
73
|
+
return statusOf(err) === 404;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isConflict(err: unknown): boolean {
|
|
77
|
+
return statusOf(err) === 409;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Delete workspace-app ConfigMaps no live pod mounts.
|
|
82
|
+
*
|
|
83
|
+
* The name is content-addressed, so every release that changes the manifest
|
|
84
|
+
* leaves the previous one behind — permanently, since nothing else removes it.
|
|
85
|
+
* Run at boot, alongside the orphan pod reap: a session that survives a runner
|
|
86
|
+
* restart is impossible (the registry is in memory), so anything a live pod
|
|
87
|
+
* still references is a pod this process is about to reap anyway.
|
|
88
|
+
*
|
|
89
|
+
* Keeps the CURRENT hash unconditionally, and skips any map a pod still lists —
|
|
90
|
+
* a mounted ConfigMap that disappears makes the kubelet fail the pod.
|
|
91
|
+
*/
|
|
92
|
+
export async function sweepWorkspaceConfigMaps(
|
|
93
|
+
kube: KubeClient,
|
|
94
|
+
config: K8sRunnerConfig,
|
|
95
|
+
log: { info(obj: unknown, msg: string): void; warn(obj: unknown, msg: string): void },
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
const keep = new Set([workspaceConfigMapName()]);
|
|
98
|
+
let pods: Awaited<ReturnType<KubeClient["core"]["listNamespacedPod"]>>;
|
|
99
|
+
let maps: Awaited<ReturnType<KubeClient["core"]["listNamespacedConfigMap"]>>;
|
|
100
|
+
try {
|
|
101
|
+
[pods, maps] = await Promise.all([
|
|
102
|
+
kube.core.listNamespacedPod({ namespace: config.sessionNamespace }),
|
|
103
|
+
kube.core.listNamespacedConfigMap({
|
|
104
|
+
namespace: config.sessionNamespace,
|
|
105
|
+
labelSelector: `app.kubernetes.io/managed-by=${config.managedByLabel}`,
|
|
106
|
+
}),
|
|
107
|
+
]);
|
|
108
|
+
} catch (err) {
|
|
109
|
+
// Housekeeping, not correctness — a runner that cannot list is a runner
|
|
110
|
+
// whose next boot will try again.
|
|
111
|
+
log.warn({ err }, "could not sweep workspace-app ConfigMaps");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
for (const pod of pods.items ?? []) {
|
|
116
|
+
for (const volume of pod.spec?.volumes ?? []) {
|
|
117
|
+
if (volume.configMap?.name) keep.add(volume.configMap.name);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let removed = 0;
|
|
122
|
+
for (const map of maps.items ?? []) {
|
|
123
|
+
const name = map.metadata?.name;
|
|
124
|
+
if (!name?.startsWith(WORKSPACE_CONFIGMAP_PREFIX) || keep.has(name)) continue;
|
|
125
|
+
try {
|
|
126
|
+
await kube.core.deleteNamespacedConfigMap({ name, namespace: config.sessionNamespace });
|
|
127
|
+
removed += 1;
|
|
128
|
+
} catch (err) {
|
|
129
|
+
if (!isNotFound(err)) log.warn({ err, name }, "failed to delete a stale workspace ConfigMap");
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (removed > 0) log.info({ removed }, "swept stale workspace-app ConfigMaps");
|
|
133
|
+
}
|
package/src/server.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { kubernetesRunnerCapabilities } from "./capabilities.js";
|
|
|
15
15
|
import { loadK8sRunnerConfig, RunnerConfigError, type K8sRunnerConfig } from "./config.js";
|
|
16
16
|
import { createKubernetesBackend } from "./k8s/backend.js";
|
|
17
17
|
import { createKubeClient } from "./k8s/client.js";
|
|
18
|
+
import { sweepWorkspaceConfigMaps } from "./k8s/workspace-configmap.js";
|
|
18
19
|
|
|
19
20
|
const VERSION: string = packageJson.version;
|
|
20
21
|
|
|
@@ -32,6 +33,7 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
32
33
|
// Load terms once; the capabilities getter is re-resolved per request so a
|
|
33
34
|
// refreshed catalog (new tags) shows up without restarting the runner.
|
|
34
35
|
const terms = loadTermsFromEnv(process.env);
|
|
36
|
+
const apps = loadResolvedApps(process.env);
|
|
35
37
|
|
|
36
38
|
const handle = await coreBuildServer({
|
|
37
39
|
backend: deps.backend,
|
|
@@ -44,11 +46,16 @@ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
|
|
|
44
46
|
defaultImage: deps.config.defaultImage,
|
|
45
47
|
terms,
|
|
46
48
|
imageEnum: catalog?.current(),
|
|
49
|
+
watch: deps.config.watch.enabled,
|
|
50
|
+
// A co-resident agent is drawn from the same catalog an app session
|
|
51
|
+
// launches from — the advertised set and the accepted set are one list,
|
|
52
|
+
// so they cannot drift.
|
|
53
|
+
agents: deps.config.watch.enabled ? Object.keys(apps) : undefined,
|
|
47
54
|
}),
|
|
48
55
|
defaultRegistryUrl: process.env.TELO_REGISTRY_URL,
|
|
49
56
|
// Operator-predefined apps (RUNNER_APPS; none when unset). Advertised on
|
|
50
57
|
// /v1/capabilities; app sessions run the catalog image directly (no build).
|
|
51
|
-
apps
|
|
58
|
+
apps,
|
|
52
59
|
validateConfig: catalog
|
|
53
60
|
? (sessionConfig: SessionConfig): string | undefined =>
|
|
54
61
|
catalog.isAllowed(sessionConfig.image)
|
|
@@ -106,6 +113,9 @@ async function main(): Promise<void> {
|
|
|
106
113
|
// Reap pods orphaned by a prior runner process (in-memory registry).
|
|
107
114
|
if (backend.reapOrphans) {
|
|
108
115
|
await backend.reapOrphans().catch((err) => app.log.warn({ err }, "orphan reap failed"));
|
|
116
|
+
// After the pods are gone: a content-addressed workspace ConfigMap outlives
|
|
117
|
+
// every release that changed the manifest, and nothing else removes it.
|
|
118
|
+
await sweepWorkspaceConfigMaps(kube, config, app.log);
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
try {
|