@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.
- package/LICENSE +17 -0
- package/README.md +206 -0
- package/dist/bundle-store.d.ts +32 -0
- package/dist/bundle-store.d.ts.map +1 -0
- package/dist/bundle-store.js +86 -0
- package/dist/bundle-store.js.map +1 -0
- package/dist/capabilities.d.ts +15 -0
- package/dist/capabilities.d.ts.map +1 -0
- package/dist/capabilities.js +30 -0
- package/dist/capabilities.js.map +1 -0
- package/dist/config.d.ts +95 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +76 -0
- package/dist/config.js.map +1 -0
- package/dist/k8s/backend.d.ts +19 -0
- package/dist/k8s/backend.d.ts.map +1 -0
- package/dist/k8s/backend.js +488 -0
- package/dist/k8s/backend.js.map +1 -0
- package/dist/k8s/client.d.ts +16 -0
- package/dist/k8s/client.d.ts.map +1 -0
- package/dist/k8s/client.js +24 -0
- package/dist/k8s/client.js.map +1 -0
- package/dist/k8s/image-build.d.ts +105 -0
- package/dist/k8s/image-build.d.ts.map +1 -0
- package/dist/k8s/image-build.js +432 -0
- package/dist/k8s/image-build.js.map +1 -0
- package/dist/k8s/ingress.d.ts +15 -0
- package/dist/k8s/ingress.d.ts.map +1 -0
- package/dist/k8s/ingress.js +102 -0
- package/dist/k8s/ingress.js.map +1 -0
- package/dist/k8s/pod-spec.d.ts +41 -0
- package/dist/k8s/pod-spec.d.ts.map +1 -0
- package/dist/k8s/pod-spec.js +146 -0
- package/dist/k8s/pod-spec.js.map +1 -0
- package/dist/limits.d.ts +25 -0
- package/dist/limits.d.ts.map +1 -0
- package/dist/limits.js +58 -0
- package/dist/limits.js.map +1 -0
- package/dist/server.d.ts +13 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +93 -0
- package/dist/server.js.map +1 -0
- package/dist/tar.d.ts +10 -0
- package/dist/tar.d.ts.map +1 -0
- package/dist/tar.js +63 -0
- package/dist/tar.js.map +1 -0
- package/package.json +41 -0
- package/src/bundle-store.ts +101 -0
- package/src/capabilities.ts +40 -0
- package/src/config.ts +201 -0
- package/src/k8s/backend-failure.test.ts +148 -0
- package/src/k8s/backend.ts +535 -0
- package/src/k8s/client.ts +39 -0
- package/src/k8s/image-build.test.ts +244 -0
- package/src/k8s/image-build.ts +547 -0
- package/src/k8s/ingress.test.ts +146 -0
- package/src/k8s/ingress.ts +127 -0
- package/src/k8s/pod-spec.ts +180 -0
- package/src/limits.test.ts +59 -0
- package/src/limits.ts +90 -0
- package/src/server.ts +124 -0
- package/src/tar.test.ts +55 -0
- 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
|
+
}
|
package/src/tar.test.ts
ADDED
|
@@ -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
|
+
});
|