Files
iomgaa cf6bc4c844 feat: 任务书 05,runner 与 tasks 两个模块
runner 用 Docker 跑一条命令并交回结局和资源用量;tasks 读 task.json、准备运行目录、给出正式运行/校验器/轻量检查的运行定义,并从原始产物独立判分。任务目录从参考项目搬到 tasks/circle_packing/,新增 task.json;vocab 的 VERDICTS 删掉"判不了",ProcessResult 的 cancelled 改为"容器从外面被杀了"。

全新上下文审阅后的两处修复:计量 shell 以容器内 root 运行,命令经 setpriv 降权,结果写在容器自己的文件层、结束后 docker cp 取出,并加 no-new-privileges,候选无法伪造计量结果把超时记成正常退出;harness 开跑前删掉旧的 solution.json、metrics.json,旧产物不会被当成本次的值。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:55:47 -04:00

225 lines
10 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import assert from "node:assert/strict";
import { spawnSync } from "node:child_process";
import { mkdirSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { after, test, type TestContext } from "node:test";
import { Runner } from "../src/runner/index.ts";
import type { ProcessResult, RunDefinition } from "../src/vocab/index.ts";
// 用真实 Docker 测(任务书 05 第五部分):没有 Docker 时整个文件失败,不跳过。
// 镜像、容器名一律 inforsi-test- 开头的固定名字;本机还有别的项目在建删容器,只动自己前缀的。
const TASK_DIR = join(import.meta.dirname, "..", "tasks", "circle_packing");
const IMAGE_TAG = "inforsi-test-circle";
const MISSING_DIGEST = `sha256:${"0".repeat(64)}`;
const runner = new Runner(null);
const digest = await runner.buildImage(TASK_DIR, IMAGE_TAG);
after(() => docker(["rmi", IMAGE_TAG]));
function docker(args: string[]): { status: number | null; stdout: string; stderr: string } {
return spawnSync("docker", args, { encoding: "utf8" });
}
/** 删掉测试自己的容器;不存在不算错,别的错误照样报。 */
function forceRemove(name: string): void {
const removed = docker(["rm", "-f", name]);
if (removed.status !== 0 && !removed.stderr.includes("No such container")) throw new Error(removed.stderr);
}
/** 一个测试用的容器名:先清掉上次没删干净的同名容器,测试结束后再删。 */
function containerName(t: TestContext, suffix: string): string {
const name = `inforsi-test-${suffix}`;
forceRemove(name);
t.after(() => forceRemove(name));
return name;
}
function dirs(t: TestContext): { runDir: string; logDir: string } {
const root = mkdtempSync(join(tmpdir(), "inforsi-runner-"));
t.after(() => rmSync(root, { recursive: true, force: true }));
const runDir = join(root, "run");
const logDir = join(root, "log");
mkdirSync(runDir);
mkdirSync(logDir);
return { runDir, logDir };
}
function definition(command: string[], wallSeconds: number, image: string): RunDefinition {
return {
command,
workdir: "/work",
image: { digest: image, runtime: "runc" },
wallSeconds,
cores: 1,
inheritEnv: false,
env: {},
};
}
const python = (code: string): string[] => ["python3", "-c", code];
/** 起一个容器并等它结束,交回结果和日志目录。 */
async function runToEnd(t: TestContext, suffix: string, command: string[], wallSeconds: number) {
const name = containerName(t, suffix);
const { runDir, logDir } = dirs(t);
const outcome = await runner.start(name, definition(command, wallSeconds, digest), runDir);
assert.deepEqual(outcome, { started: true });
const result = await runner.wait(name, logDir);
return { name, result, logDir };
}
function value(result: ProcessResult, key: "cpuSeconds" | "wallSeconds" | "peakMemoryBytes"): number {
assert.notEqual(result.ending, "notStarted");
if (result.ending === "notStarted") throw new Error("unreachable");
const item = result[key];
assert.equal(item.status, "measured", `${key} 应当测到`);
if (item.status !== "measured") throw new Error("unreachable");
return item.value;
}
test("建镜像命中参考项目的缓存;查镜像:有的答有,没有的答没有", async () => {
assert.match(digest, /^sha256:7ed52793365c/);
assert.equal(await runner.hasImage(digest), true);
assert.equal(await runner.hasImage(MISSING_DIGEST), false);
});
test("正常退出:带退出码和三项资源用量,标准输出、标准错误存成两个文件", async (t) => {
const code = "import sys; print('out'); print('err', file=sys.stderr); sys.exit(3)";
const { result, logDir } = await runToEnd(t, "exit", python(code), 30);
assert.equal(result.ending, "exited");
assert.equal(result.ending === "exited" && result.exitCode, 3);
assert.ok(value(result, "cpuSeconds") > 0);
assert.ok(value(result, "wallSeconds") > 0 && value(result, "wallSeconds") < 30);
assert.ok(value(result, "peakMemoryBytes") > 0);
assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "out\n");
assert.equal(readFileSync(join(logDir, "stderr.log"), "utf8"), "err\n");
});
test("超时:结局 timeout,CPU 秒接近时限,墙钟不短于时限", async (t) => {
const { result } = await runToEnd(t, "timeout", python("while True: pass"), 2);
assert.equal(result.ending, "timeout");
const cpu = value(result, "cpuSeconds");
assert.ok(cpu > 1.7 && cpu < 2.3, `CPU 秒 ${cpu}`);
assert.ok(value(result, "wallSeconds") >= 2);
});
test("候选伪造计量结果再死循环:伪造不成,结局仍是 timeout,资源用量是真实测到的;写出的文件归当前用户", async (t) => {
// 审阅时实测过的手法:先写一份假的 usage,再把目录改成只读,然后死循环到超时。
const code = [
"import os",
"for attempt in (lambda: open('/inforsi-record/usage', 'w').write('exited 0 1 1 1\\n'), lambda: os.chmod('/inforsi-record', 0o555)):",
" try:",
" attempt()",
" except OSError as error:",
" print(type(error).__name__, flush=True)",
"open('out.txt', 'w').write('x')",
"while True: pass",
].join("\n");
const name = containerName(t, "forge");
const { runDir, logDir } = dirs(t);
assert.deepEqual(await runner.start(name, definition(python(code), 2, digest), runDir), { started: true });
const result = await runner.wait(name, logDir);
assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "PermissionError\nPermissionError\n");
assert.equal(result.ending, "timeout");
const cpu = value(result, "cpuSeconds");
t.diagnostic(`结局 ${result.ending},CPU 秒 ${cpu},墙钟秒 ${value(result, "wallSeconds")}`);
assert.ok(cpu > 1.7 && cpu < 2.3, `CPU 秒 ${cpu}`);
assert.ok(value(result, "wallSeconds") >= 2);
assert.equal(statSync(join(runDir, "out.txt")).uid, process.getuid?.());
});
test("命令自己以 124 或 137 退出,不判成超时", async (t) => {
const own124 = await runToEnd(t, "exit124", python("import sys; sys.exit(124)"), 30);
assert.equal(own124.result.ending, "exited");
assert.equal(own124.result.ending === "exited" && own124.result.exitCode, 124);
const own137 = await runToEnd(t, "exit137", python("import os; os.kill(os.getpid(), 9)"), 30);
assert.equal(own137.result.ending, "exited");
assert.equal(own137.result.ending === "exited" && own137.result.exitCode, 137);
});
test("从外面 docker kill:结局 cancelled,三项资源都测不到并写明原因", async (t) => {
const name = containerName(t, "killed");
const { runDir, logDir } = dirs(t);
const outcome = await runner.start(name, definition(python("import time; time.sleep(60)"), 120, digest), runDir);
assert.deepEqual(outcome, { started: true });
assert.equal(await runner.status(name), "running");
const killed = docker(["kill", name]);
assert.equal(killed.status, 0, killed.stderr);
const result = await runner.wait(name, logDir);
assert.equal(result.ending, "cancelled");
if (result.ending !== "cancelled") return;
for (const item of [result.cpuSeconds, result.wallSeconds, result.peakMemoryBytes]) {
assert.equal(item.status, "unmeasured");
assert.match(item.status === "unmeasured" ? item.reason : "", /从外面被结束/);
}
});
test("镜像编号不存在:没启动,原因是 Docker 的报错原文,也没留下容器", async (t) => {
const name = containerName(t, "noimage");
const { runDir } = dirs(t);
const outcome = await runner.start(name, definition(python("pass"), 30, MISSING_DIGEST), runDir);
assert.equal(outcome.started, false);
assert.match(outcome.started ? "" : outcome.reason, /No such image/);
assert.equal(await runner.status(name), "absent");
});
test("断网:容器里连不上外网", async (t) => {
const code = [
"import socket, sys",
"try:",
" socket.create_connection(('1.1.1.1', 53), timeout=3)",
"except OSError as error:",
" print(type(error).__name__); sys.exit(0)",
"sys.exit(1)",
].join("\n");
const { result } = await runToEnd(t, "network", python(code), 30);
assert.equal(result.ending === "exited" && result.exitCode, 0);
});
test("核数:容器里 os.cpu_count() 报 1", async (t) => {
const { logDir } = await runToEnd(t, "cores", python("import os; print(os.cpu_count())"), 30);
assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "1\n");
});
test("查状态:没有这个容器、建了没启动、在跑、已结束、问不到", async (t) => {
assert.equal(await runner.status(containerName(t, "status-absent")), "absent");
const created = containerName(t, "status-created");
const create = docker(["create", "--name", created, digest, "true"]);
assert.equal(create.status, 0, create.stderr);
assert.equal(await runner.status(created), "created");
const name = containerName(t, "status-running");
const { runDir, logDir } = dirs(t);
await runner.start(name, definition(python("import time; time.sleep(3)"), 30, digest), runDir);
assert.equal(await runner.status(name), "running");
await runner.wait(name, logDir);
assert.equal(await runner.status(name), "exited");
const nowhere = new Runner("unix:///nonexistent/docker.sock");
assert.equal(await nowhere.status(name), "unreachable");
await runner.remove(name);
assert.equal(await runner.status(name), "absent");
});
test("等结束能接上另一个 Runner 实例启动的容器;已结束的容器再等一次,结果相同", async (t) => {
const name = containerName(t, "resume");
const { runDir, logDir } = dirs(t);
const starter = new Runner(null);
await starter.start(name, definition(python("import time; time.sleep(2); print('done')"), 30, digest), runDir);
const resumed = new Runner(null);
const first = await resumed.wait(name, logDir);
assert.equal(first.ending === "exited" && first.exitCode, 0);
assert.equal(readFileSync(join(logDir, "stdout.log"), "utf8"), "done\n");
const again = await new Runner(null).wait(name, logDir);
assert.deepEqual(again, first);
});
test("容器名不符合 Docker 的要求时报错", async () => {
await assert.rejects(runner.status("inforsi test"), /不符合 Docker 的要求/);
await assert.rejects(runner.status("-x"), /不符合 Docker 的要求/);
});