@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.
@@ -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: loadResolvedApps(process.env),
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 {