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>
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
// runner:用 Docker 把一条命令跑完,交回它怎么结束的、用了多少资源(docs/结构.md 第二部分)。
|
||||
export { Runner, type ContainerStatus, type StartOutcome } from "./runner.ts";
|
||||
@@ -0,0 +1,269 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { closeSync, openSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import type { Measured, ProcessResult, RunDefinition } from "../vocab/index.ts";
|
||||
|
||||
// 用 Docker 把一条命令跑完:建镜像、起容器、等结束、查状态、删容器(docs/结构.md 第二部分)。
|
||||
// runner 不碰账本:登记要跑、确认启动、写结果都由 flow 做(方法.md 第九部分)。
|
||||
// 本身不存状态,全部依据都在 Docker 里,所以另一个进程里新建的 Runner 也能接着等一个早先启动的容器。
|
||||
|
||||
/** 查状态的五种回答。unreachable 是 Docker 不应答,flow 据此记"状态未知"并停下(方法.md 第七、第九部分)。 */
|
||||
export type ContainerStatus = "absent" | "created" | "running" | "exited" | "unreachable";
|
||||
|
||||
/** 启动的结果:启动了,或者没启动和 Docker 的报错原文。 */
|
||||
export type StartOutcome = { started: true } | { started: false; reason: string };
|
||||
|
||||
/** Docker 对容器名的要求(与 Docker 源码里的 RestrictedNamePattern 相同)。 */
|
||||
const CONTAINER_NAME = /^[a-zA-Z0-9][a-zA-Z0-9_.-]+$/;
|
||||
|
||||
/** 容器里的固定主机名:默认主机名是容器编号前 12 位,每次不同,镜像编号钉不住它。 */
|
||||
const HOSTNAME = "inforsi";
|
||||
|
||||
/**
|
||||
* 计量结果在容器自己文件层里的位置,不挂到宿主。目录由容器里的 root 建、只有 root 可写,
|
||||
* 降权后的命令既写不进去也改不了权限,伪造不了结果。
|
||||
*/
|
||||
const RECORD_DIR = "/inforsi-record";
|
||||
const USAGE_FILE = "usage";
|
||||
|
||||
/**
|
||||
* 容器里的第一个进程,以容器内 root 身份运行。参数依次是时限(纳秒)、时限(秒,交给 timeout)、
|
||||
* 降权到的用户编号和组编号、真正的命令。
|
||||
* - timeout 和这段 shell 是 root,被 timeout 包着的 setpriv 降到宿主当前用户再执行命令:
|
||||
* 命令写进运行目录的文件在宿主上归当前用户,宿主能读产物和清理;命令也没法向 root 进程发信号。
|
||||
* setpriv 不改环境变量,命令的环境仍只有镜像的和 docker run 给的。
|
||||
* - 用 KILL 结束超时的命令:它不能被捕获,候选不能拖延;timeout 此时的退出码是 128+9=137。
|
||||
* - 超时由这段 shell 自己确认:退出码 137 且用时不短于时限。命令自己以 124 或 137 结束时,
|
||||
* 用时不到时限,不会被当成超时。
|
||||
* - 资源用量读容器自己的 cgroup v2(cgroup 命名空间下 /sys/fs/cgroup 就是本容器),
|
||||
* 命令被 timeout 杀掉之后照样读得到;从外面杀掉整个容器时这段 shell 也一起没了,结果文件不会出现。
|
||||
* - 先写临时文件再改名:结果文件要么完整,要么不存在。
|
||||
*/
|
||||
const MEASURE_SCRIPT = `limit_ns=$1; limit=$2; uid=$3; gid=$4; shift 4
|
||||
mkdir -m 700 ${RECORD_DIR}
|
||||
start=$(date +%s%N)
|
||||
timeout -s KILL "$limit" setpriv --reuid="$uid" --regid="$gid" --clear-groups -- "$@"
|
||||
code=$?
|
||||
end=$(date +%s%N)
|
||||
wall=$((end - start))
|
||||
ending=exited
|
||||
if [ "$code" -eq 137 ] && [ "$wall" -ge "$limit_ns" ]; then ending=timeout; fi
|
||||
cpu=$(sed -n 's/^usage_usec //p' /sys/fs/cgroup/cpu.stat)
|
||||
mem=$(cat /sys/fs/cgroup/memory.peak)
|
||||
echo "$ending $code $wall $cpu $mem" > ${RECORD_DIR}/${USAGE_FILE}.tmp
|
||||
mv ${RECORD_DIR}/${USAGE_FILE}.tmp ${RECORD_DIR}/${USAGE_FILE}
|
||||
`;
|
||||
|
||||
/** 结果文件的格式:结局、退出码、墙钟纳秒、CPU 微秒、峰值内存字节,空格分隔,一行。 */
|
||||
const USAGE_LINE = /^(exited|timeout) (\d+) (\d+) (\d+) (\d+)\n$/;
|
||||
|
||||
/** docker cp 找不到容器里的文件时的报错。 */
|
||||
const NO_SUCH_FILE = "Could not find the file";
|
||||
|
||||
/** 认得出的"Docker 不应答"报错:本地套接字连不上、TCP 地址连不上两种写法。 */
|
||||
const UNREACHABLE = /failed to connect to the docker API|Cannot connect to the Docker daemon/;
|
||||
|
||||
interface Done {
|
||||
code: number;
|
||||
stdout: string;
|
||||
stderr: string;
|
||||
}
|
||||
|
||||
export class Runner {
|
||||
readonly #hostArgs: string[];
|
||||
|
||||
/**
|
||||
* dockerHost:Docker 守护进程的地址,例如 unix:///var/run/docker.sock;
|
||||
* null 表示不加 -H,由 docker 命令自己按 DOCKER_HOST 环境变量和当前 context 决定。
|
||||
*/
|
||||
constructor(dockerHost: string | null) {
|
||||
this.#hostArgs = dockerHost === null ? [] : ["-H", dockerHost];
|
||||
}
|
||||
|
||||
/** 用目录里的 Dockerfile 建镜像,打上 tag,交回镜像编号(`docker image inspect -f '{{.Id}}'` 的值)。 */
|
||||
async buildImage(contextDir: string, tag: string): Promise<string> {
|
||||
const built = await this.#docker(["build", "-t", tag, contextDir]);
|
||||
if (built.code !== 0) throw new Error(`docker build ${contextDir} 失败:\n${built.stderr}`);
|
||||
const inspected = await this.#docker(["image", "inspect", "--format", "{{.Id}}", tag]);
|
||||
if (inspected.code !== 0) throw new Error(`docker image inspect ${tag} 失败:${inspected.stderr}`);
|
||||
const digest = inspected.stdout.trim();
|
||||
if (!/^sha256:[0-9a-f]{64}$/.test(digest)) {
|
||||
throw new Error(`docker image inspect ${tag} 交回的不是镜像编号:${inspected.stdout}`);
|
||||
}
|
||||
return digest;
|
||||
}
|
||||
|
||||
/** 本机有没有这个镜像编号。Docker 不应答或报别的错,直接报错。 */
|
||||
async hasImage(digest: string): Promise<boolean> {
|
||||
const inspected = await this.#docker(["image", "inspect", "--format", "{{.Id}}", digest]);
|
||||
if (inspected.code === 0) return true;
|
||||
if (inspected.stderr.includes("No such image")) return false;
|
||||
throw new Error(`docker image inspect ${digest} 失败:${inspected.stderr}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* 起一个容器跑运行定义里的命令,runDir 挂到 definition.workdir。
|
||||
* docker run 失败时按名字再查一次(方法.md 第七部分"不确定有没有启动时,先核对"):
|
||||
* 容器不存在或建了没启动,算没启动(建了的顺手删掉);在跑或已结束,算启动了。
|
||||
*/
|
||||
async start(name: string, definition: RunDefinition, runDir: string): Promise<StartOutcome> {
|
||||
checkName(name);
|
||||
const getuid = process.getuid;
|
||||
const getgid = process.getgid;
|
||||
if (getuid === undefined || getgid === undefined) throw new Error("runner 只能在 POSIX 系统上用:读不到当前用户编号");
|
||||
const wallNs = Math.ceil(definition.wallSeconds * 1e9);
|
||||
const args = [
|
||||
"run",
|
||||
"--detach",
|
||||
"--name",
|
||||
name,
|
||||
// 本机 Docker 的默认运行时不一定是 runc(这台机器是 nvidia),跟着默认走,换台机器环境就悄悄变了。
|
||||
"--runtime",
|
||||
definition.image.runtime,
|
||||
// 镜像只按编号用本机已有的,不去仓库拉:拉到的可能是另一份内容。
|
||||
"--pull",
|
||||
"never",
|
||||
// 第一个进程是 root,好让计量结果不归命令管;命令本身由计量 shell 降到宿主当前用户再跑。
|
||||
"--user",
|
||||
"0:0",
|
||||
// 镜像里有 su、mount 这类 setuid 程序;禁止靠它们重新拿到 root,降权后的命令就碰不到计量结果。
|
||||
// setpriv 降权是 root 主动换身份,不受这条限制。
|
||||
"--security-opt",
|
||||
"no-new-privileges",
|
||||
"--hostname",
|
||||
HOSTNAME,
|
||||
// 方法要求运行时不联网。
|
||||
"--network",
|
||||
"none",
|
||||
// --cpus 限总量,不固定到某个核。PYTHON_CPU_COUNT 让 os.cpu_count() 报这个核数(Python 3.13 起),
|
||||
// 否则它报宿主核数,候选会开一堆进程抢一个核。写在 env 之后:同名时 docker 取后一个。
|
||||
`--cpus=${definition.cores}`,
|
||||
// 降权后的用户在镜像里没有家目录;HOME 不写明的话会是 root 的 /root。写在 env 之前,运行定义可以改。
|
||||
"--env",
|
||||
"HOME=/",
|
||||
...Object.entries(definition.env).flatMap(([key, value]) => ["--env", `${key}=${value}`]),
|
||||
"--env",
|
||||
`PYTHON_CPU_COUNT=${definition.cores}`,
|
||||
// 用 --mount 而不是 -v:-v 遇到相对路径会悄悄建一个同名数据卷,--mount 直接报错。
|
||||
"--mount",
|
||||
`type=bind,source=${runDir},target=${definition.workdir}`,
|
||||
"--workdir",
|
||||
definition.workdir,
|
||||
// 第一个进程固定是计量 shell,不受镜像 ENTRYPOINT 影响。
|
||||
"--entrypoint",
|
||||
"/bin/sh",
|
||||
// 不加 --rm:中断恢复时要按名字找到这个容器。
|
||||
definition.image.digest,
|
||||
"-c",
|
||||
MEASURE_SCRIPT,
|
||||
"inforsi-measure",
|
||||
String(wallNs),
|
||||
String(definition.wallSeconds),
|
||||
String(getuid.call(process)),
|
||||
String(getgid.call(process)),
|
||||
...definition.command,
|
||||
];
|
||||
const run = await this.#docker(args);
|
||||
if (run.code === 0) return { started: true };
|
||||
const status = await this.status(name);
|
||||
if (status === "running" || status === "exited") return { started: true };
|
||||
if (status === "unreachable") {
|
||||
throw new Error(`docker run ${name} 失败,之后查状态时 Docker 不应答,不知道有没有启动。docker run 的报错:${run.stderr}`);
|
||||
}
|
||||
if (status === "created") await this.remove(name);
|
||||
return { started: false, reason: run.stderr.trim() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 等容器结束,交回进程层面的结果。标准输出、标准错误存成 logDir 下的 stdout.log、stderr.log,
|
||||
* 计量结果文件从容器里拷出来存成 logDir 下的 usage(容器结束后 docker cp 照样能拷)。
|
||||
* 只靠名字,能接上别的进程启动的容器;容器早已结束时 docker wait 立刻返回。
|
||||
*/
|
||||
async wait(name: string, logDir: string): Promise<ProcessResult> {
|
||||
checkName(name);
|
||||
const waited = await this.#docker(["wait", name]);
|
||||
if (waited.code !== 0) throw new Error(`docker wait ${name} 失败:${waited.stderr}`);
|
||||
await this.#saveLogs(name, logDir);
|
||||
const usagePath = join(logDir, USAGE_FILE);
|
||||
const copied = await this.#docker(["cp", `${name}:${RECORD_DIR}/${USAGE_FILE}`, usagePath]);
|
||||
if (copied.code !== 0) {
|
||||
if (!copied.stderr.includes(NO_SUCH_FILE)) throw new Error(`docker cp ${name} 的计量结果失败:${copied.stderr}`);
|
||||
const reason = `容器从外面被结束(容器退出码 ${waited.stdout.trim()}),计量 shell 没有写出结果文件`;
|
||||
const unmeasured: Measured = { status: "unmeasured", reason };
|
||||
return { ending: "cancelled", cpuSeconds: unmeasured, wallSeconds: unmeasured, peakMemoryBytes: unmeasured };
|
||||
}
|
||||
const text = readFileSync(usagePath, "utf8");
|
||||
const match = USAGE_LINE.exec(text);
|
||||
if (match === null) throw new Error(`${usagePath} 的格式认不出:${JSON.stringify(text)}`);
|
||||
const [, ending, code, wallNs, cpuUsec, memBytes] = match as unknown as [string, string, string, string, string, string];
|
||||
const usage = {
|
||||
cpuSeconds: measured(Number(cpuUsec) / 1e6),
|
||||
wallSeconds: measured(Number(wallNs) / 1e9),
|
||||
peakMemoryBytes: measured(Number(memBytes)),
|
||||
};
|
||||
if (ending === "timeout") return { ending: "timeout", ...usage };
|
||||
return { ending: "exited", exitCode: Number(code), ...usage };
|
||||
}
|
||||
|
||||
/** 按名字查容器的状态。Docker 报出这五种以外的状态(paused、restarting、dead 等)时报错。 */
|
||||
async status(name: string): Promise<ContainerStatus> {
|
||||
checkName(name);
|
||||
const inspected = await this.#docker(["container", "inspect", "--format", "{{.State.Status}}", name]);
|
||||
if (inspected.code !== 0) {
|
||||
if (inspected.stderr.includes("No such container")) return "absent";
|
||||
if (UNREACHABLE.test(inspected.stderr)) return "unreachable";
|
||||
throw new Error(`docker container inspect ${name} 失败:${inspected.stderr}`);
|
||||
}
|
||||
const state = inspected.stdout.trim();
|
||||
if (state === "created" || state === "running" || state === "exited") return state;
|
||||
throw new Error(`容器 ${name} 处于认不出的状态:${state}`);
|
||||
}
|
||||
|
||||
/** 删掉一个已结束或没启动的容器。还在跑的容器 docker 拒绝删,这里照样报错。 */
|
||||
async remove(name: string): Promise<void> {
|
||||
checkName(name);
|
||||
const removed = await this.#docker(["rm", name]);
|
||||
if (removed.code !== 0) throw new Error(`docker rm ${name} 失败:${removed.stderr}`);
|
||||
}
|
||||
|
||||
/** 两条流直接写进文件,不经过内存:候选可能打印很多内容。恢复时可能再等一次,所以覆盖已有的文件。 */
|
||||
async #saveLogs(name: string, logDir: string): Promise<void> {
|
||||
const out = openSync(join(logDir, "stdout.log"), "w");
|
||||
const err = openSync(join(logDir, "stderr.log"), "w");
|
||||
try {
|
||||
const code = await new Promise<number | null>((resolve, reject) => {
|
||||
const child = spawn("docker", [...this.#hostArgs, "logs", name], { stdio: ["ignore", out, err] });
|
||||
child.on("error", reject);
|
||||
child.on("close", resolve);
|
||||
});
|
||||
if (code !== 0) throw new Error(`docker logs ${name} 失败,退出码 ${code},详情见 ${logDir}/stderr.log`);
|
||||
} finally {
|
||||
closeSync(out);
|
||||
closeSync(err);
|
||||
}
|
||||
}
|
||||
|
||||
/** 调一次 docker 命令,收齐输出。docker 命令本身起不来或被信号杀掉时报错。 */
|
||||
#docker(args: string[]): Promise<Done> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("docker", [...this.#hostArgs, ...args], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
child.stdout.setEncoding("utf8").on("data", (chunk: string) => (stdout += chunk));
|
||||
child.stderr.setEncoding("utf8").on("data", (chunk: string) => (stderr += chunk));
|
||||
child.on("error", reject);
|
||||
child.on("close", (code, signal) => {
|
||||
if (code === null) reject(new Error(`docker ${args[0]} 被信号 ${signal} 结束`));
|
||||
else resolve({ code, stdout, stderr });
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function checkName(name: string): void {
|
||||
if (!CONTAINER_NAME.test(name)) throw new Error(`容器名 ${JSON.stringify(name)} 不符合 Docker 的要求 ${CONTAINER_NAME}`);
|
||||
}
|
||||
|
||||
function measured(value: number): Measured {
|
||||
return { status: "measured", value };
|
||||
}
|
||||
Reference in New Issue
Block a user