@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.
Files changed (63) hide show
  1. package/LICENSE +17 -0
  2. package/README.md +206 -0
  3. package/dist/bundle-store.d.ts +32 -0
  4. package/dist/bundle-store.d.ts.map +1 -0
  5. package/dist/bundle-store.js +86 -0
  6. package/dist/bundle-store.js.map +1 -0
  7. package/dist/capabilities.d.ts +15 -0
  8. package/dist/capabilities.d.ts.map +1 -0
  9. package/dist/capabilities.js +30 -0
  10. package/dist/capabilities.js.map +1 -0
  11. package/dist/config.d.ts +95 -0
  12. package/dist/config.d.ts.map +1 -0
  13. package/dist/config.js +76 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/k8s/backend.d.ts +19 -0
  16. package/dist/k8s/backend.d.ts.map +1 -0
  17. package/dist/k8s/backend.js +488 -0
  18. package/dist/k8s/backend.js.map +1 -0
  19. package/dist/k8s/client.d.ts +16 -0
  20. package/dist/k8s/client.d.ts.map +1 -0
  21. package/dist/k8s/client.js +24 -0
  22. package/dist/k8s/client.js.map +1 -0
  23. package/dist/k8s/image-build.d.ts +105 -0
  24. package/dist/k8s/image-build.d.ts.map +1 -0
  25. package/dist/k8s/image-build.js +432 -0
  26. package/dist/k8s/image-build.js.map +1 -0
  27. package/dist/k8s/ingress.d.ts +15 -0
  28. package/dist/k8s/ingress.d.ts.map +1 -0
  29. package/dist/k8s/ingress.js +102 -0
  30. package/dist/k8s/ingress.js.map +1 -0
  31. package/dist/k8s/pod-spec.d.ts +41 -0
  32. package/dist/k8s/pod-spec.d.ts.map +1 -0
  33. package/dist/k8s/pod-spec.js +146 -0
  34. package/dist/k8s/pod-spec.js.map +1 -0
  35. package/dist/limits.d.ts +25 -0
  36. package/dist/limits.d.ts.map +1 -0
  37. package/dist/limits.js +58 -0
  38. package/dist/limits.js.map +1 -0
  39. package/dist/server.d.ts +13 -0
  40. package/dist/server.d.ts.map +1 -0
  41. package/dist/server.js +93 -0
  42. package/dist/server.js.map +1 -0
  43. package/dist/tar.d.ts +10 -0
  44. package/dist/tar.d.ts.map +1 -0
  45. package/dist/tar.js +63 -0
  46. package/dist/tar.js.map +1 -0
  47. package/package.json +41 -0
  48. package/src/bundle-store.ts +101 -0
  49. package/src/capabilities.ts +40 -0
  50. package/src/config.ts +201 -0
  51. package/src/k8s/backend-failure.test.ts +148 -0
  52. package/src/k8s/backend.ts +535 -0
  53. package/src/k8s/client.ts +39 -0
  54. package/src/k8s/image-build.test.ts +244 -0
  55. package/src/k8s/image-build.ts +547 -0
  56. package/src/k8s/ingress.test.ts +146 -0
  57. package/src/k8s/ingress.ts +127 -0
  58. package/src/k8s/pod-spec.ts +180 -0
  59. package/src/limits.test.ts +59 -0
  60. package/src/limits.ts +90 -0
  61. package/src/server.ts +124 -0
  62. package/src/tar.test.ts +55 -0
  63. package/src/tar.ts +68 -0
@@ -0,0 +1,127 @@
1
+ import type { V1Ingress, V1OwnerReference, V1Service } from "@kubernetes/client-node";
2
+
3
+ import type { K8sRunnerConfig } from "../config.js";
4
+ import type { PortMapping, RunnerEndpoint } from "@telorun/runner-core";
5
+
6
+ /** OwnerReference to the session Pod so the Service + Ingress are garbage
7
+ * collected automatically when the Pod dies — essential for sub-minute
8
+ * sessions that would otherwise leak ingress objects. */
9
+ function podOwnerRef(podName: string, podUid: string): V1OwnerReference {
10
+ return {
11
+ apiVersion: "v1",
12
+ kind: "Pod",
13
+ name: podName,
14
+ uid: podUid,
15
+ controller: true,
16
+ blockOwnerDeletion: true,
17
+ };
18
+ }
19
+
20
+ export function buildSessionService(
21
+ config: K8sRunnerConfig,
22
+ sessionId: string,
23
+ podName: string,
24
+ podUid: string,
25
+ ports: PortMapping[],
26
+ ): V1Service {
27
+ return {
28
+ apiVersion: "v1",
29
+ kind: "Service",
30
+ metadata: {
31
+ name: `telo-run-${sessionId}`,
32
+ namespace: config.sessionNamespace,
33
+ labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
34
+ ownerReferences: [podOwnerRef(podName, podUid)],
35
+ },
36
+ spec: {
37
+ selector: { "telo.run/session-id": sessionId },
38
+ ports: ports.map((p) => ({
39
+ name: `p${p.port}`,
40
+ port: p.port,
41
+ targetPort: p.port,
42
+ protocol: p.protocol.toUpperCase(),
43
+ })),
44
+ },
45
+ };
46
+ }
47
+
48
+ /** Host fronting a single tcp port: `<port>-<sessionId>.<domain>`. The port
49
+ * rides as a leading label (no dots), so it stays a single label under the base
50
+ * domain — matching the docker runner's proxy scheme and compatible with a
51
+ * single-label wildcard cert (`*.<domain>`). */
52
+ function hostForPort(config: K8sRunnerConfig, sessionId: string, port: number): string {
53
+ return `${port}-${sessionId}.${config.sessionIngressBaseDomain}`;
54
+ }
55
+
56
+ export function buildSessionIngress(
57
+ config: K8sRunnerConfig,
58
+ sessionId: string,
59
+ serviceName: string,
60
+ podName: string,
61
+ podUid: string,
62
+ ports: PortMapping[],
63
+ ): { ingress: V1Ingress; hosts: string[] } {
64
+ // Only tcp ports are HTTP-routable; one host rule per port to the matching
65
+ // service port, mirroring the docker runner's per-port URLs.
66
+ const rules = ports
67
+ .filter((p) => p.protocol === "tcp")
68
+ .map((p) => ({
69
+ host: hostForPort(config, sessionId, p.port),
70
+ http: {
71
+ paths: [
72
+ {
73
+ path: "/",
74
+ pathType: "Prefix" as const,
75
+ backend: { service: { name: serviceName, port: { number: p.port } } },
76
+ },
77
+ ],
78
+ },
79
+ }));
80
+ const ingress: V1Ingress = {
81
+ apiVersion: "networking.k8s.io/v1",
82
+ kind: "Ingress",
83
+ metadata: {
84
+ name: `telo-run-${sessionId}`,
85
+ namespace: config.sessionNamespace,
86
+ labels: { "app.kubernetes.io/managed-by": config.managedByLabel },
87
+ ownerReferences: [podOwnerRef(podName, podUid)],
88
+ },
89
+ spec: {
90
+ ...(config.sessionIngressClassName
91
+ ? { ingressClassName: config.sessionIngressClassName }
92
+ : {}),
93
+ // Present the predefined cert (e.g. a Cloudflare Origin cert) so an upstream
94
+ // in Full (Strict) mode can validate the origin. Only meaningful with routable
95
+ // hosts; a single wildcard `*.<domain>` Secret covers every session host.
96
+ ...(config.sessionIngressTlsSecretName && rules.length > 0
97
+ ? {
98
+ tls: [{ hosts: rules.map((r) => r.host), secretName: config.sessionIngressTlsSecretName }],
99
+ }
100
+ : {}),
101
+ rules,
102
+ },
103
+ };
104
+ return { ingress, hosts: rules.map((r) => r.host) };
105
+ }
106
+
107
+ /** Endpoints announced on the `running` status. Every tcp port is fronted by its
108
+ * own per-session Ingress host (`<port>-<sessionId>.<domain>`, served on 443) and
109
+ * carries an external `url`. udp ports aren't HTTP-routable, so they keep the
110
+ * host-less form. Without an ingress base domain, host is left blank for the
111
+ * client adapter to fill (parity with docker). */
112
+ export function endpointsFor(
113
+ config: K8sRunnerConfig,
114
+ sessionId: string,
115
+ ports: PortMapping[],
116
+ ): RunnerEndpoint[] {
117
+ if (!config.sessionIngressBaseDomain || ports.length === 0) {
118
+ return ports.map((p) => ({ host: "", port: p.port, protocol: p.protocol }));
119
+ }
120
+ return ports.map((p) => {
121
+ if (p.protocol !== "tcp") {
122
+ return { host: "", port: p.port, protocol: p.protocol };
123
+ }
124
+ const host = hostForPort(config, sessionId, p.port);
125
+ return { host, port: p.port, protocol: p.protocol, url: `https://${host}` };
126
+ });
127
+ }
@@ -0,0 +1,180 @@
1
+ import type { V1Pod } from "@kubernetes/client-node";
2
+
3
+ import type { K8sRunnerConfig } from "../config.js";
4
+ import type { ResolvedLimits } from "../limits.js";
5
+ import type { PortMapping } from "@telorun/runner-core";
6
+
7
+ export interface BuildPodArgs {
8
+ config: K8sRunnerConfig;
9
+ sessionId: string;
10
+ podName: string;
11
+ entryRelativePath: string;
12
+ env: Record<string, string>;
13
+ ports: PortMapping[];
14
+ limits: ResolvedLimits;
15
+ /** Prebuilt per-app image to run. Controllers + module manifests are baked
16
+ * into `/telo-cache/{manifests,npm}` (read-only) by the on-cluster build;
17
+ * the image is keyed only on the dependency closure, so the per-session body
18
+ * is NOT baked — it's delivered to `/app` at boot via the initContainer. */
19
+ image: string;
20
+ /** Tokenized, single-use URL the body-delivery initContainer fetches the
21
+ * session bundle tarball from (`BundleStore.stageSessionBundle`). */
22
+ bundleUrl: string;
23
+ /** When true, run the workload with `--inspect` so the runner can relay its
24
+ * kernel debug stream. Binds `0.0.0.0:<INSPECT_PORT>` (reachable only by the
25
+ * runner over the cluster pod network — never exposed via Service/Ingress). */
26
+ inspect: boolean;
27
+ }
28
+
29
+ /** Port the workload's `--inspect` server binds inside the session container.
30
+ * Reached by the runner over the cluster pod network (`http://<podIP>:<port>`);
31
+ * never declared as a Service port — only the runner relays the stream out. */
32
+ export const INSPECT_PORT = 9230;
33
+
34
+ const APP_DIR = "/app";
35
+ const WORK_DIR = "/work";
36
+ /** Baked, read-only deps (`telo install` output). Set via TELO_CACHE_DIR, NOT
37
+ * mounted — it lives on the image rootfs, so `telo run --no-cache-write` reads
38
+ * it without writing. */
39
+ const DEPS_DIR = "/telo-cache";
40
+ /** Writable HOME / npm scratch under a read-only rootfs. Separate from DEPS_DIR
41
+ * (which is now read-only baked deps, not scratch). */
42
+ const HOME_DIR = "/home/telo";
43
+ const TMP_MOUNT = "/tmp";
44
+
45
+ /**
46
+ * Builds the session Pod. Hardening that needs no RuntimeClass is always on
47
+ * (non-root, read-only rootfs, drop-all caps, no service-account token,
48
+ * seccomp RuntimeDefault); a sandbox RuntimeClass is layered on when configured.
49
+ *
50
+ * The body-delivery initContainer fetches the session bundle into the writable
51
+ * `/app` emptyDir; the session container runs `telo run /app/<entry>
52
+ * --no-cache-write` reading its deps from the baked, read-only `/telo-cache`.
53
+ * `readOnlyRootFilesystem` stays on — every write lands on a mounted emptyDir.
54
+ */
55
+ export function buildSessionPod(args: BuildPodArgs): V1Pod {
56
+ const { config, limits } = args;
57
+
58
+ const resources = {
59
+ limits: {
60
+ cpu: limits.cpu,
61
+ memory: limits.memory,
62
+ "ephemeral-storage": limits.ephemeralStorage,
63
+ },
64
+ requests: {
65
+ cpu: limits.cpu,
66
+ memory: limits.memory,
67
+ },
68
+ };
69
+
70
+ const envVars = Object.entries(args.env).map(([name, value]) => ({ name, value }));
71
+ // Read deps from the baked, read-only `/telo-cache`; keep HOME/npm scratch on a
72
+ // separate writable emptyDir under the read-only root filesystem.
73
+ envVars.push({ name: "TELO_CACHE_DIR", value: DEPS_DIR });
74
+ envVars.push({ name: "HOME", value: HOME_DIR });
75
+ envVars.push({ name: "npm_config_cache", value: `${HOME_DIR}/.npm` });
76
+ envVars.push({ name: "FORCE_COLOR", value: "1" });
77
+
78
+ const pod: V1Pod = {
79
+ apiVersion: "v1",
80
+ kind: "Pod",
81
+ metadata: {
82
+ name: args.podName,
83
+ namespace: config.sessionNamespace,
84
+ labels: {
85
+ "app.kubernetes.io/managed-by": config.managedByLabel,
86
+ "telo.run/session-id": args.sessionId,
87
+ },
88
+ },
89
+ spec: {
90
+ restartPolicy: "Never",
91
+ activeDeadlineSeconds: limits.ttlSeconds,
92
+ automountServiceAccountToken: false,
93
+ // Pull the per-app image from a private registry. The Secret must exist in
94
+ // the session namespace (pull secrets are namespace-scoped).
95
+ ...(config.build.imagePullSecret
96
+ ? { imagePullSecrets: [{ name: config.build.imagePullSecret }] }
97
+ : {}),
98
+ ...(config.runtimeClass ? { runtimeClassName: config.runtimeClass } : {}),
99
+ securityContext: {
100
+ runAsNonRoot: true,
101
+ runAsUser: 1000,
102
+ runAsGroup: 1000,
103
+ fsGroup: 1000,
104
+ seccompProfile: { type: "RuntimeDefault" },
105
+ },
106
+ initContainers: [
107
+ {
108
+ // Deliver the per-session body into the writable /app emptyDir. The
109
+ // image bakes only the dependency closure, so the body arrives here.
110
+ name: "body-fetch",
111
+ image: config.initImage,
112
+ command: ["sh", "-c"],
113
+ args: [
114
+ `set -e; wget -qO /tmp/body.tgz "${args.bundleUrl}"; tar xzf /tmp/body.tgz -C ${APP_DIR}`,
115
+ ],
116
+ volumeMounts: [
117
+ { name: "app", mountPath: APP_DIR },
118
+ { name: "tmp", mountPath: TMP_MOUNT },
119
+ ],
120
+ securityContext: hardenedContainerSecurity(),
121
+ },
122
+ ],
123
+ containers: [
124
+ {
125
+ name: "session",
126
+ image: args.image,
127
+ // The per-app tag is an immutable content hash, so IfNotPresent lets
128
+ // the kubelet reuse a node-cached layer across runs of the same app.
129
+ imagePullPolicy: "IfNotPresent",
130
+ // Run the delivered body by absolute path; `--no-cache-write` reads
131
+ // the baked deps from TELO_CACHE_DIR and validates in-memory without
132
+ // touching the read-only cache. WORK_DIR is a writable emptyDir cwd so
133
+ // the workload's relative paths resolve under readOnlyRootFilesystem.
134
+ workingDir: WORK_DIR,
135
+ // 0.0.0.0 (not the CLI's loopback default) lets the runner reach the
136
+ // debug server across the pod network; the port is never published.
137
+ command: [
138
+ "telo",
139
+ "run",
140
+ `${APP_DIR}/${args.entryRelativePath}`,
141
+ "--no-cache-write",
142
+ ...(args.inspect ? ["--inspect", `0.0.0.0:${INSPECT_PORT}`, "--no-open"] : []),
143
+ ],
144
+ env: envVars,
145
+ stdin: true,
146
+ stdinOnce: false,
147
+ tty: true,
148
+ ...(args.ports.length > 0
149
+ ? { ports: args.ports.map((p) => ({ containerPort: p.port, protocol: p.protocol.toUpperCase() })) }
150
+ : {}),
151
+ resources,
152
+ volumeMounts: [
153
+ { name: "app", mountPath: APP_DIR },
154
+ { name: "work", mountPath: WORK_DIR },
155
+ { name: "home", mountPath: HOME_DIR },
156
+ { name: "tmp", mountPath: TMP_MOUNT },
157
+ ],
158
+ securityContext: hardenedContainerSecurity(),
159
+ },
160
+ ],
161
+ volumes: [
162
+ { name: "app", emptyDir: {} },
163
+ { name: "work", emptyDir: {} },
164
+ { name: "home", emptyDir: {} },
165
+ { name: "tmp", emptyDir: {} },
166
+ ],
167
+ },
168
+ };
169
+
170
+ return pod;
171
+ }
172
+
173
+ function hardenedContainerSecurity(): Record<string, unknown> {
174
+ return {
175
+ allowPrivilegeEscalation: false,
176
+ readOnlyRootFilesystem: true,
177
+ runAsNonRoot: true,
178
+ capabilities: { drop: ["ALL"] },
179
+ };
180
+ }
@@ -0,0 +1,59 @@
1
+ import { describe, expect, it } from "vitest";
2
+
3
+ import type { LimitCeilings } from "./config.js";
4
+ import { clampLimits, parseCpuMillis, parseMemoryBytes } from "./limits.js";
5
+
6
+ const CEILINGS: LimitCeilings = {
7
+ cpu: "50m",
8
+ memory: "100Mi",
9
+ ttlSeconds: 3600,
10
+ ephemeralStorage: "512Mi",
11
+ };
12
+
13
+ describe("clampLimits — hard ceiling, clamp-down only", () => {
14
+ it("uses the ceiling when no request is given", () => {
15
+ expect(clampLimits(CEILINGS, undefined)).toEqual({
16
+ cpu: "50m",
17
+ memory: "100Mi",
18
+ ttlSeconds: 3600,
19
+ ephemeralStorage: "512Mi",
20
+ });
21
+ });
22
+
23
+ it("clamps a request that exceeds the ceiling back to the ceiling", () => {
24
+ const r = clampLimits(CEILINGS, { cpu: "2", memory: "4Gi", ttlSeconds: 99_999 });
25
+ expect(r.cpu).toBe("50m");
26
+ expect(r.memory).toBe("100Mi");
27
+ expect(r.ttlSeconds).toBe(3600);
28
+ });
29
+
30
+ it("honors a request that asks for LESS than the ceiling", () => {
31
+ const r = clampLimits(CEILINGS, { cpu: "20m", memory: "64Mi", ttlSeconds: 600 });
32
+ expect(r.cpu).toBe("20m");
33
+ expect(r.memory).toBe("64Mi");
34
+ expect(r.ttlSeconds).toBe(600);
35
+ });
36
+
37
+ it("falls back to the ceiling on an unparseable request", () => {
38
+ const r = clampLimits(CEILINGS, { cpu: "garbage", memory: "??" });
39
+ expect(r.cpu).toBe("50m");
40
+ expect(r.memory).toBe("100Mi");
41
+ });
42
+ });
43
+
44
+ describe("quantity parsers", () => {
45
+ it("parses cpu to millicores", () => {
46
+ expect(parseCpuMillis("50m")).toBe(50);
47
+ expect(parseCpuMillis("1")).toBe(1000);
48
+ expect(parseCpuMillis("0.5")).toBe(500);
49
+ expect(parseCpuMillis("nope")).toBeNull();
50
+ });
51
+
52
+ it("parses memory to bytes (binary + SI)", () => {
53
+ expect(parseMemoryBytes("100Mi")).toBe(100 * 1024 * 1024);
54
+ expect(parseMemoryBytes("1Gi")).toBe(1024 ** 3);
55
+ expect(parseMemoryBytes("1000")).toBe(1000);
56
+ expect(parseMemoryBytes("5M")).toBe(5_000_000);
57
+ expect(parseMemoryBytes("bad")).toBeNull();
58
+ });
59
+ });
package/src/limits.ts ADDED
@@ -0,0 +1,90 @@
1
+ import type { LimitCeilings } from "./config.js";
2
+
3
+ /**
4
+ * Effective per-session resource limits. Requests may ask for LESS than the
5
+ * ceiling but never MORE — `min(requested, ceiling)`. Without a control plane
6
+ * the editor talks to the runner directly, so a raisable limit would void the
7
+ * cap; clamp-down-only is the load-bearing invariant.
8
+ */
9
+ export interface ResolvedLimits {
10
+ cpu: string;
11
+ memory: string;
12
+ ttlSeconds: number;
13
+ ephemeralStorage: string;
14
+ }
15
+
16
+ export interface RequestedLimits {
17
+ cpu?: string;
18
+ memory?: string;
19
+ ttlSeconds?: number;
20
+ ephemeralStorage?: string;
21
+ }
22
+
23
+ export function clampLimits(
24
+ ceilings: LimitCeilings,
25
+ requested: RequestedLimits | undefined,
26
+ ): ResolvedLimits {
27
+ return {
28
+ cpu: clampQuantity(ceilings.cpu, requested?.cpu, parseCpuMillis),
29
+ memory: clampQuantity(ceilings.memory, requested?.memory, parseMemoryBytes),
30
+ ephemeralStorage: clampQuantity(
31
+ ceilings.ephemeralStorage,
32
+ requested?.ephemeralStorage,
33
+ parseMemoryBytes,
34
+ ),
35
+ ttlSeconds:
36
+ requested?.ttlSeconds && requested.ttlSeconds > 0
37
+ ? Math.min(requested.ttlSeconds, ceilings.ttlSeconds)
38
+ : ceilings.ttlSeconds,
39
+ };
40
+ }
41
+
42
+ /** Returns `requested` only when it parses AND is <= ceiling; otherwise the
43
+ * ceiling. Any unparseable/oversized request silently clamps to the cap. */
44
+ function clampQuantity(
45
+ ceiling: string,
46
+ requested: string | undefined,
47
+ parse: (q: string) => number | null,
48
+ ): string {
49
+ if (!requested) return ceiling;
50
+ const r = parse(requested);
51
+ const c = parse(ceiling);
52
+ if (r === null || c === null) return ceiling;
53
+ return r <= c ? requested : ceiling;
54
+ }
55
+
56
+ /** CPU → millicores. "500m" → 500; "1" → 1000; "0.5" → 500. */
57
+ export function parseCpuMillis(q: string): number | null {
58
+ const s = q.trim();
59
+ if (s.endsWith("m")) {
60
+ const n = Number(s.slice(0, -1));
61
+ return Number.isFinite(n) ? n : null;
62
+ }
63
+ const n = Number(s);
64
+ return Number.isFinite(n) ? Math.round(n * 1000) : null;
65
+ }
66
+
67
+ const MEMORY_SUFFIXES: Record<string, number> = {
68
+ Ki: 1024,
69
+ Mi: 1024 ** 2,
70
+ Gi: 1024 ** 3,
71
+ Ti: 1024 ** 4,
72
+ K: 1000,
73
+ M: 1000 ** 2,
74
+ G: 1000 ** 3,
75
+ T: 1000 ** 4,
76
+ k: 1000,
77
+ };
78
+
79
+ /** Memory/storage quantity → bytes. Supports binary (Ki/Mi/Gi) and SI (K/M/G). */
80
+ export function parseMemoryBytes(q: string): number | null {
81
+ const s = q.trim();
82
+ const match = /^(\d+(?:\.\d+)?)\s*([A-Za-z]+)?$/.exec(s);
83
+ if (!match) return null;
84
+ const value = Number(match[1]);
85
+ if (!Number.isFinite(value)) return null;
86
+ const suffix = match[2];
87
+ if (!suffix) return value;
88
+ const mult = MEMORY_SUFFIXES[suffix];
89
+ return mult ? value * mult : null;
90
+ }
package/src/server.ts ADDED
@@ -0,0 +1,124 @@
1
+ import {
2
+ BaseImageCatalog,
3
+ buildServer as coreBuildServer,
4
+ loadTermsFromEnv,
5
+ stopAllSessions,
6
+ type RunnerBackend,
7
+ type ServerHandle,
8
+ type SessionConfig,
9
+ } from "@telorun/runner-core";
10
+
11
+ import packageJson from "../package.json" with { type: "json" };
12
+ import { BundleStore } from "./bundle-store.js";
13
+ import { kubernetesRunnerCapabilities } from "./capabilities.js";
14
+ import { loadK8sRunnerConfig, RunnerConfigError, type K8sRunnerConfig } from "./config.js";
15
+ import { createKubernetesBackend } from "./k8s/backend.js";
16
+ import { createKubeClient } from "./k8s/client.js";
17
+
18
+ const VERSION: string = packageJson.version;
19
+
20
+ export interface ServerDeps {
21
+ backend: RunnerBackend;
22
+ config: K8sRunnerConfig;
23
+ bundleStore: BundleStore;
24
+ /** Base-image catalog whose resolved list becomes the advertised `image`
25
+ * enum + the server-side allowlist. Omitted → `image` locks to defaultImage. */
26
+ catalog?: BaseImageCatalog;
27
+ }
28
+
29
+ export async function buildServer(deps: ServerDeps): Promise<ServerHandle> {
30
+ const { catalog } = deps;
31
+ // Load terms once; the capabilities getter is re-resolved per request so a
32
+ // refreshed catalog (new tags) shows up without restarting the runner.
33
+ const terms = loadTermsFromEnv(process.env);
34
+
35
+ const handle = await coreBuildServer({
36
+ backend: deps.backend,
37
+ config: deps.config,
38
+ version: VERSION,
39
+ capabilities: () =>
40
+ kubernetesRunnerCapabilities(deps.config.defaultImage, terms, catalog?.current()),
41
+ defaultRegistryUrl: process.env.TELO_REGISTRY_URL,
42
+ validateConfig: catalog
43
+ ? (sessionConfig: SessionConfig): string | undefined =>
44
+ catalog.isAllowed(sessionConfig.image)
45
+ ? undefined
46
+ : `base image '${sessionConfig.image}' is not offered by this runner. ` +
47
+ `Allowed images: ${catalog.current().join(", ")}`
48
+ : undefined,
49
+ });
50
+ // Mount the internal, tokenized fetch route on the same app so a build Job's
51
+ // initContainer can pull the build-context tarball (bundle + Dockerfile).
52
+ deps.bundleStore.registerRoute(handle.app);
53
+ return handle;
54
+ }
55
+
56
+ async function main(): Promise<void> {
57
+ let config: K8sRunnerConfig;
58
+ try {
59
+ config = loadK8sRunnerConfig(process.env);
60
+ } catch (err) {
61
+ if (err instanceof RunnerConfigError) {
62
+ process.stderr.write(`${err.message}\n`);
63
+ process.exit(2);
64
+ }
65
+ throw err;
66
+ }
67
+
68
+ const kube = createKubeClient();
69
+ const bundleStore = new BundleStore(config.selfUrl);
70
+ const backend = createKubernetesBackend({ kube, config, bundleStore });
71
+
72
+ const catalog = config.baseImageCatalog.enabled
73
+ ? new BaseImageCatalog({
74
+ repository: config.baseImageCatalog.repository,
75
+ defaultRef: config.defaultImage,
76
+ filter: config.baseImageCatalog.filter,
77
+ limit: config.baseImageCatalog.limit,
78
+ refreshIntervalMs: config.baseImageCatalog.refreshIntervalMs,
79
+ })
80
+ : undefined;
81
+
82
+ const { app, registry } = await buildServer({ backend, config, bundleStore, catalog });
83
+
84
+ // Populate the catalog before serving so the first /v1/capabilities carries the
85
+ // full menu; a fetch failure degrades to the default image (surfaced, not
86
+ // swallowed) and the periodic refresh retries.
87
+ if (catalog) {
88
+ await catalog
89
+ .refresh()
90
+ .catch((err) =>
91
+ app.log.warn({ err }, "initial base-image catalog refresh failed; serving default image only"),
92
+ );
93
+ catalog.start((err) => app.log.warn({ err }, "base-image catalog refresh failed"));
94
+ }
95
+
96
+ // Reap pods orphaned by a prior runner process (in-memory registry).
97
+ if (backend.reapOrphans) {
98
+ await backend.reapOrphans().catch((err) => app.log.warn({ err }, "orphan reap failed"));
99
+ }
100
+
101
+ try {
102
+ await app.listen({ port: config.port, host: "0.0.0.0" });
103
+ } catch (err) {
104
+ app.log.error(err);
105
+ process.exit(1);
106
+ }
107
+
108
+ const shutdown = async (signal: string): Promise<void> => {
109
+ app.log.info({ signal }, "shutting down");
110
+ catalog?.stop();
111
+ for (const entry of registry.list()) entry.userStopped = true;
112
+ await app.close();
113
+ await stopAllSessions(registry, app.log);
114
+ process.exit(0);
115
+ };
116
+
117
+ process.on("SIGTERM", () => void shutdown("SIGTERM"));
118
+ process.on("SIGINT", () => void shutdown("SIGINT"));
119
+ }
120
+
121
+ const isEntrypoint = import.meta.url === `file://${process.argv[1]}`;
122
+ if (isEntrypoint) {
123
+ void main();
124
+ }
@@ -0,0 +1,55 @@
1
+ import { gunzipSync } from "node:zlib";
2
+ import { describe, expect, it } from "vitest";
3
+
4
+ import { makeBundleTarGz } from "./tar.js";
5
+
6
+ /** Reads the file names + sizes out of an uncompressed ustar buffer. */
7
+ function listTar(buf: Buffer): Array<{ name: string; size: number; body: string }> {
8
+ const out: Array<{ name: string; size: number; body: string }> = [];
9
+ let off = 0;
10
+ while (off + 512 <= buf.byteLength) {
11
+ const block = buf.subarray(off, off + 512);
12
+ const name = block.subarray(0, 100).toString("ascii").replace(/\0.*$/, "");
13
+ if (name === "") break; // terminator
14
+ const size = parseInt(block.subarray(124, 136).toString("ascii").replace(/\0.*$/, "").trim(), 8);
15
+ const body = buf.subarray(off + 512, off + 512 + size).toString("utf8");
16
+ out.push({ name, size, body });
17
+ off += 512 + Math.ceil(size / 512) * 512;
18
+ }
19
+ return out;
20
+ }
21
+
22
+ describe("makeBundleTarGz", () => {
23
+ it("produces a gzip ustar archive round-trippable to the original files", async () => {
24
+ const gz = await makeBundleTarGz({
25
+ entryRelativePath: "telo.yaml",
26
+ files: [
27
+ { relativePath: "telo.yaml", contents: "kind: Telo.Application\n" },
28
+ { relativePath: "sub/lib.yaml", contents: "kind: Telo.Library\n" },
29
+ ],
30
+ });
31
+ const entries = listTar(gunzipSync(gz));
32
+ expect(entries.map((e) => e.name)).toEqual(["telo.yaml", "sub/lib.yaml"]);
33
+ expect(entries[0]!.body).toBe("kind: Telo.Application\n");
34
+ expect(entries[1]!.body).toBe("kind: Telo.Library\n");
35
+ });
36
+
37
+ it("rejects traversal paths", async () => {
38
+ await expect(
39
+ makeBundleTarGz({
40
+ entryRelativePath: "telo.yaml",
41
+ files: [{ relativePath: "../escape", contents: "x" }],
42
+ }),
43
+ ).rejects.toThrow();
44
+ });
45
+
46
+ it("rejects paths longer than the 100-byte ustar name field", async () => {
47
+ const longName = "a/".repeat(60) + "x.yaml"; // > 100 bytes
48
+ await expect(
49
+ makeBundleTarGz({
50
+ entryRelativePath: "telo.yaml",
51
+ files: [{ relativePath: longName, contents: "x" }],
52
+ }),
53
+ ).rejects.toThrow(/too long for tar/);
54
+ });
55
+ });