Files
ollama-plus/coderunner/index.ts
T

332 lines
10 KiB
TypeScript
Raw Normal View History

2025-09-10 16:26:35 -04:00
import http from "node:http";
2025-09-12 11:20:18 -04:00
import * as k8s from "@kubernetes/client-node";
2025-09-10 16:26:35 -04:00
const PORT = Number(process.env.PORT || 8787);
const HOST = "0.0.0.0";
/**
* @example const imgObj = { image: "gcc:14", installcommands: [aptUpdateAndInstall("gpp")], filename: "main.cpp", run: ... },
*/
const aptUpdateAndInstall = (pkgs: string[] | string) => {
const istr = typeof pkgs === 'string' ? pkgs : pkgs.join(" ");
return `apt-get update \
&& apt-get install -y --no-install-recommends ${istr} \
&& rm -rf /var/lib/apt/lists/*;`
}
// allow-listed images and run commands per language
const LANGS = {
python: { image: "python:3.12-alpine", filename: "main.py", run: ["python", "main.py"] },
node: { image: "node:22-alpine", filename: "main.mjs", run: ["node", "main.mjs"] },
bun: { image: "oven/bun:1.2.2-alpine", filename: "main.ts", run: ["bun", "run", "main.ts"] },
bash: { image: "alpine:3.20", filename: "main.sh", run: ["sh", "main.sh"] },
ruby: { image: "ruby:3.3-alpine", filename: "main.rb", run: ["ruby", "main.rb"] },
go: { image: "golang:1.22-alpine", filename: "main.go", run: ["go run main.go"] },
rust: { image: "rust:1-alpine", filename: "main.rs", run: ["rustc -O main.rs -o main && ./main"] },
java: { image: "eclipse-temurin:21-jdk", filename: "Main.java", run: ["javac Main.java && java Main"] },
c: { image: "gcc:14", filename: "main.c", run: ["gcc -O2 main.c -o main.out && ./main.out"] },
cpp: { image: "gcc:14", filename: "main.cpp", run: ["g++ -O2 main.cpp -o main.out && ./main.out"] },
};
type langObj = {
image: string,
filename: string,
run: string[],
installcommands?: string[]
}
type fileType = {
path: string,
content: string
}
2025-09-12 11:20:18 -04:00
const NS = process.env.NAMESPACE || "ai";
const kc = new k8s.KubeConfig();
kc.loadFromDefault(); // in-cluster uses serviceaccount
const batch = kc.makeApiClient(k8s.BatchV1Api),
core = kc.makeApiClient(k8s.CoreV1Api);
2025-09-10 16:26:35 -04:00
// basic openapi for open webui
const OPENAPI = {
openapi: "3.1.0",
info: {
title: "Container Code Runner",
version: "1.0.0",
description:
"run source code inside a sandboxed container. important: provide pure source code only; do not wrap code in shell commands or pipelines."
},
paths: {
"/execute": {
post: {
operationId: "execute",
summary: "Run code in a sandboxed container",
// the model sees this text
description:
"use the language directly, not bash + the language. e.g., `#include...` (good) vs `echo '#include...' && gcc` (bad). pass only pure source text in `code`.",
requestBody: {
required: true,
content: {
"application/json": {
schema: {
type: "object",
properties: {
language: {
type: "string",
enum: Object.keys(LANGS),
description:
"the programming language to run. do not use 'bash' to wrap or invoke compilers/interpreters; select the actual language (e.g., 'c', 'cpp', 'python')."
},
code: {
type: "string",
description:
"pure source code only. do not include shell commands, redirections, pipes, or `echo`/`printf` wrappers. examples: good: `print('hi')`; bad: `echo \"print('hi')\" | python`."
},
args: { type: "array", items: { type: "string" } },
files: {
type: "array",
items: {
type: "object",
properties: {
path: { type: "string" },
content: { type: "string" }
},
required: ["path", "content"],
description:
"optional supporting files. contents must be pure file text, not shell commands."
}
}
},
required: ["language", "code"]
}
}
}
},
responses: {
"200": {
description: "Execution result",
content: {
"application/json": {
schema: {
type: "object",
properties: {
stdout: { type: "string" },
stderr: { type: "string" },
exitCode: { type: "integer" },
timedOut: { type: "boolean" }
}
}
}
}
}
}
}
}
}
};
2025-09-12 11:20:18 -04:00
function sendJson(res: any, status: number, obj: any) {
2025-09-10 16:26:35 -04:00
const body = JSON.stringify(obj);
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
res.end(body);
}
async function ensureImage(spec: langObj) {
// note: 'docker run' has a --pull policy (missing|always|never),
const { spawn } = await import("node:child_process"),
DOCKER_BIN = process.env.DOCKER_BIN || "docker";
// check if image exists locally
const inspect = spawn(DOCKER_BIN, ["image", "inspect", spec.image], { stdio: "ignore" }),
ok = await new Promise((r) => inspect.on("close", (c) => r(c === 0)));
if (ok) return;
// pull with a bigger timeout the first time (4 minutes)
await new Promise((resolve) => {
const child = spawn(DOCKER_BIN, ["image", "pull", "--quiet", spec.image]);
let timer = setTimeout(() => {
child.kill("SIGKILL");
resolve(new Error("pull timeout"));
}, 240_000);
child.on("close", (code) => {
clearTimeout(timer);
resolve(code === 0 ? null : new Error(`pull failed: ${code}`));
});
}).then((err) => {
if (err) throw err;
});
// // dependancies
// if (spec.installcommands) {
// const commandFull = spec.installcommands.join(" && ");
// await new Promise((resolve) => {
// const child = spawn(DOCKER_BIN, ["image", "pull", "--quiet", spec.image]);
// let timer = setTimeout(() => {
// child.kill("SIGKILL");
// resolve(new Error("pull timeout"));
// }, 240_000);
// child.on("close", (code) => {
// clearTimeout(timer);
// resolve(code === 0 ? null : new Error(`pull failed: ${code}`));
// });
// }).then((err) => {
// if (err) throw err;
// })
// }
}
2025-09-12 11:20:18 -04:00
async function waitForJobPod(core: k8s.CoreV1Api, jobName: string): Promise<string> {
const labelSelector = `job-name=${jobName}`;
for (; ;) {
const pods = await core.listNamespacedPod({ namespace: NS, labelSelector });
const pod = pods.items.find((p) => p.status?.phase === "Running" || p.status?.phase === "Succeeded" || p.status?.phase === "Failed");
if (pod?.metadata?.name) return pod.metadata.name;
await new Promise((r) => setTimeout(r, 400));
}
};
async function waitForCompletionAndLogs(core: k8s.CoreV1Api, podName: string): Promise<{ status: string; stdout: string; stderr: string; }> {
for (; ;) {
const readReq = {
name: podName,
namespace: NS
},
p = await core.readNamespacedPod(readReq),
phase = p.status?.phase ?? "Pending";
if (phase === "Succeeded" || phase === "Failed") {
const logs = await core.readNamespacedPodLog(readReq);
// stderr is not separated by the api; you can split by stream if needed
return { status: phase, stdout: logs, stderr: "" };
};
await new Promise((r) => setTimeout(r, 500));
}
};
2025-09-10 16:26:35 -04:00
async function runInContainer({ language, code, args = [], files = [] }: {
2025-09-12 11:20:18 -04:00
language: string, code: string, args: string[], files: fileType[]
2025-09-10 16:26:35 -04:00
}) {
2025-09-12 11:20:18 -04:00
if (!(language in LANGS)) throw new Error(`language not allowed: ${language}`);
const spec = LANGS[language as keyof typeof LANGS];
// build the same shell script you already use
2025-09-10 16:26:35 -04:00
const script = [
`echo ${JSON.stringify(Buffer.from(code, "utf8").toString("base64"))} | base64 -d > ${spec.filename}`,
...files.flatMap((f) => [
`mkdir -p "$(dirname "${f.path}")"`,
`echo ${JSON.stringify(Buffer.from(f.content, "utf8").toString("base64"))} | base64 -d > "${f.path}"`,
]),
`${spec.run.join(' ')} ${args.map((a) => JSON.stringify(a)).join(' ')}`
].join('\n');
2025-09-12 11:20:18 -04:00
const uid = crypto.randomUUID().slice(0, 8);
const jobName = `coderun-${uid}`;
// create a short-lived Job with tight security and resource caps
const job: k8s.V1Job = {
apiVersion: "batch/v1",
kind: "Job",
metadata: { name: jobName, namespace: NS },
spec: {
ttlSecondsAfterFinished: 300, // auto-clean once done
backoffLimit: 0, // no retries
activeDeadlineSeconds: 25, // mirrors your 25s timeout
template: {
metadata: { labels: { app: "coderunner-task" } },
spec: {
restartPolicy: "Never",
securityContext: {
runAsNonRoot: true,
seccompProfile: { type: "RuntimeDefault" }
},
containers: [{
name: "task",
image: spec.image,
command: ["sh", "-lc", script],
resources: {
requests: { cpu: "1", memory: "512Mi" },
limits: { cpu: "1", memory: "512Mi" }
},
securityContext: {
allowPrivilegeEscalation: false,
readOnlyRootFilesystem: true,
capabilities: { drop: ["ALL"] }
}
}]
}
}
}
};
2025-09-10 16:26:35 -04:00
2025-09-12 11:20:18 -04:00
await batch.createNamespacedJob({
namespace: NS,
body: job
2025-09-10 16:26:35 -04:00
});
2025-09-12 11:20:18 -04:00
// wait for pod to complete, then get logs
const podName = await waitForJobPod(core, jobName);
const { status, stdout, stderr } = await waitForCompletionAndLogs(core, podName);
// delete job for hygiene (ttl also cleans it eventually)
try {
await batch.deleteNamespacedJob({ namespace: NS, propagationPolicy: "Background", name: jobName });
} catch (err) { console.error(err); };
return {
stdout,
stderr,
exitCode: status === "Succeeded" ? 0 : 1,
timedOut: status === "Failed"
};
2025-09-10 16:26:35 -04:00
}
const server = http.createServer(async (req, res) => {
try {
console.debug(`recieved ${req.method} request on ${req.url}`);
if (req.method === "GET" && req.url === "/openapi.json") {
sendJson(res, 200, OPENAPI);
return;
}
if (req.method === "GET" && req.url === "/") {
res.writeHead(200);
res.end("Ok");
return;
}
if (req.method === "POST" && req.url === "/execute") {
let body = "";
req.on("data", (c) => { body += c.toString(); });
req.on("end", async () => {
try {
const payload = JSON.parse(body || "{}");
const out = await runInContainer(payload);
sendJson(res, 200, out);
2025-09-12 11:20:18 -04:00
} catch (e: any) {
2025-09-10 16:26:35 -04:00
sendJson(res, 400, { error: String(e?.message || e) });
}
});
return;
}
res.writeHead(404);
res.end("not found");
2025-09-12 11:20:18 -04:00
} catch (e: any) {
2025-09-10 16:26:35 -04:00
sendJson(res, 500, { error: String(e?.message || e) });
}
});
server.listen(PORT, HOST, () => {
console.log(`[runner] listening on http://${HOST}:${PORT}`);
});