fix(system): 编译产物读 /proc、/sys 走 cat 兜底,运行状态资源监控与沙箱内存看门狗恢复

Deno 2 不带 --allow-all 时读 /proc、/sys 一律 NotCapable,部署环境资源监控一直无数据。

Co-Authored-By: Claude Opus 5.5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01Cxwgnn2pbzQ7rXv3rq7fei
This commit is contained in:
rootandClaude Opus 5.5 committed 2026-10-06 23:40:41 +08:00
1 parent 708e99746c
commit 9268a4d920
9 files changed
+83 -18

No files matched your search

+2 -2
View File
@@ -4,8 +4,8 @@
"exports": { ".": "./src/main.ts" }, "exports": { ".": "./src/main.ts" },
"tasks": { "tasks": {
"dev": "deno run -A --watch --env-file=../../.env src/main.ts", "dev": "deno run -A --watch --env-file=../../.env src/main.ts",
"start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,deno --env-file=../../.env src/main.ts", "start": "deno run --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,deno --env-file=../../.env src/main.ts",
"compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server --include ../../packages/db/migrations --include ../../packages/agent/skills --output ../../dist/umt-server src/main.ts", "compile": "deno compile --frozen --node-modules-dir=none --exclude-unused-npm --allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server --include ../../packages/db/migrations --include ../../packages/agent/skills --output ../../dist/umt-server src/main.ts",
"test": "deno run -A npm:vitest run", "test": "deno run -A npm:vitest run",
"typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json"
} }
+3 -7
View File
@@ -1,6 +1,7 @@
import { availableParallelism, cpus } from "node:os"; import { availableParallelism, cpus } from "node:os";
import process from "node:process"; import process from "node:process";
import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts"; import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts";
import { readProcText } from "../../proc-read.ts";
// 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2, // 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2,
// 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。 // 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。
@@ -14,13 +15,8 @@ const MAX_POINTS = 360;
export type ReadText = (path: string) => Promise<string | null>; export type ReadText = (path: string) => Promise<string | null>;
export const defaultReadText: ReadText = async (path) => { /** /proc 与 /sys 需特权读,编译产物走 cat 兜底(见 proc-read.ts) */
try { export const defaultReadText: ReadText = readProcText;
return await Deno.readTextFile(path);
} catch {
return null;
}
};
// ── 解析(纯函数) ── // ── 解析(纯函数) ──
+29
View File
@@ -0,0 +1,29 @@
// 读 /proc 与 /sys 的伪文件。
// Deno 2 把这两处当特权路径:不带 --allow-all 时 Deno.readTextFile 一律 NotCapable(--allow-read 及按路径授权都不行)。
// 开发 / 测试用 -A 能直接读;编译产物按最小权限运行,只能借 `cat` 子进程读(编译与 start 任务的 --allow-run 已含 cat)。
// 第一次遇到 NotCapable 后记住,后续直接走 cat。
let viaCat = false;
async function catText(path: string): Promise<string | null> {
try {
const o = await new Deno.Command("cat", { args: [path], stdout: "piped", stderr: "null" }).output();
return o.success ? new TextDecoder().decode(o.stdout) : null;
} catch {
return null;
}
}
/** 读不到(不存在、无权限、cat 不可用)返回 null */
export async function readProcText(path: string): Promise<string | null> {
if (viaCat) return await catText(path);
try {
return await Deno.readTextFile(path);
} catch (e) {
if (e instanceof Deno.errors.NotCapable) {
viaCat = true;
return await catText(path);
}
return null;
}
}
+3 -6
View File
@@ -3,6 +3,7 @@
// 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。 // 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。
import { basename } from "node:path"; import { basename } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import { readProcText } from "../proc-read.ts";
import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts"; import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts";
export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn"; export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn";
@@ -52,13 +53,9 @@ function command(memoryMb: number): { cmd: string; args: string[] } {
/** 子进程实际内存(KB);读不到返回 null */ /** 子进程实际内存(KB);读不到返回 null */
async function rssKb(pid: number): Promise<number | null> { async function rssKb(pid: number): Promise<number | null> {
try { const s = await readProcText(`/proc/${pid}/status`);
const s = await Deno.readTextFile(`/proc/${pid}/status`); const m = s ? /VmRSS:\s+(\d+)\s+kB/.exec(s) : null;
const m = /VmRSS:\s+(\d+)\s+kB/.exec(s);
return m ? Number(m[1]) : null; return m ? Number(m[1]) : null;
} catch {
return null;
}
} }
export async function runSandbox<J extends SandboxJobName>( export async function runSandbox<J extends SandboxJobName>(
+42
View File
@@ -0,0 +1,42 @@
// 生产权限下读 /proc、/sys:Deno 2 不带 --allow-all 时 readTextFile 一律 NotCapable,
// 编译产物的资源监控与沙箱内存看门狗曾因此静默取不到数(v0.3.34 运行状态页一直无数据)。
// 这里用与 apps/server/deno.json compile 相同的权限起子进程,确认走 cat 兜底能读到。
import { fileURLToPath } from "node:url";
import { describe, expect, it } from "vitest";
const here = fileURLToPath(new URL(".", import.meta.url));
const probe = `
import { readProcText } from "${here}../../src/proc-read.ts";
import { readResources } from "${here}../../src/modules/system/resources.ts";
const stat = await readProcText("/proc/stat");
const r = await readResources();
console.log(JSON.stringify({ stat: !!stat && stat.startsWith("cpu"), host: r.host != null, mem: r.meminfo != null }));
`;
describe.skipIf(Deno.build.os !== "linux")("readProcText 在生产权限下", () => {
it("走 cat 兜底读到 /proc 与资源读数", async () => {
const out = await new Deno.Command(Deno.execPath(), {
args: [
"run",
"--no-prompt",
"--allow-net",
"--allow-env",
"--allow-sys",
"--allow-read",
"--allow-write",
"--allow-run=ffprobe,sha256sum,cat",
"-",
],
stdin: "piped",
stdout: "piped",
stderr: "piped",
}).spawn();
const w = out.stdin.getWriter();
await w.write(new TextEncoder().encode(probe));
await w.close();
const o = await out.output();
const text = new TextDecoder().decode(o.stdout).trim();
expect(o.success, new TextDecoder().decode(o.stderr)).toBe(true);
expect(JSON.parse(text.split("\n").at(-1)!)).toEqual({ stat: true, host: true, mem: true });
}, 60_000);
});
+1 -1
View File
@@ -178,5 +178,5 @@ deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest
讨论过换 Bun / Node 24,结论是不换。 讨论过换 Bun / Node 24,结论是不换。
- 实际权限:生产编译参数为 `--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server`,只有 `--allow-run` 白名单真正起限制,上文"读路径白名单"没有落实;选 Deno 的理由以直接跑 TS、单二进制、集中的 workspace 配置为主。 - 实际权限:生产编译参数为 `--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server`,只有 `--allow-run` 白名单真正起限制,上文"读路径白名单"没有落实;选 Deno 的理由以直接跑 TS、单二进制、集中的 workspace 配置为主。
- 已知摩擦都有固定做法:web 测试用 Node 跑 vitest、编译产物自检兜底漏嵌、`deno.lock` 被常驻进程改写(见 `docs/plan/lessons.md`)。 - 已知摩擦都有固定做法:web 测试用 Node 跑 vitest、编译产物自检兜底漏嵌、`deno.lock` 被常驻进程改写(见 `docs/plan/lessons.md`)。
+1
View File
@@ -46,3 +46,4 @@
29. 两路各自合理的改动合起来出问题:B 路以「种子被占用」取消计划,C 路让「取消的计划立即让位」,合并后会取消 → 重评 → 再取消空转 → **规则**:协调者合并时专门看跨线交互(状态取值、原因码、让位 / 重试规则),补一条跨线测试。 29. 两路各自合理的改动合起来出问题:B 路以「种子被占用」取消计划,C 路让「取消的计划立即让位」,合并后会取消 → 重评 → 再取消空转 → **规则**:协调者合并时专门看跨线交互(状态取值、原因码、让位 / 重试规则),补一条跨线测试。
30. 发布构建在镜像里下载 Deno 运行时(`dl.deno.land`),构建容器直连外网卡了两个多小时没报错(v0.3.29)→ **规则**:`.env.deploy` 配 `BUILD_PROXY`,`release.sh` 自动带构建代理参数;发布超过 10 分钟没动静先看构建日志卡在哪。 30. 发布构建在镜像里下载 Deno 运行时(`dl.deno.land`),构建容器直连外网卡了两个多小时没报错(v0.3.29)→ **规则**:`.env.deploy` 配 `BUILD_PROXY`,`release.sh` 自动带构建代理参数;发布超过 10 分钟没动静先看构建日志卡在哪。
31. 运行状态页的 CPU / 内存一直没有数据(v0.3.32–0.3.34):Deno 2 把 `/proc`、`/sys` 当特权路径,不带 `--allow-all` 时 `readTextFile` 一律 NotCapable(按路径授权也不行);开发和测试都用 `-A`,只有编译产物按最小权限运行,于是只在部署环境静默失效(沙箱的内存看门狗同样失效)→ **规则**:读 `/proc`、`/sys` 一律用 `readProcText`(`cat` 兜底);依赖运行权限的功能要有一条按编译权限起子进程的测试(见 `test/system/proc-read.test.ts`)。
+1 -1
View File
@@ -18,7 +18,7 @@ APP_VERSION=v0.3.11 docker compose --env-file .env.deploy build app # 示例:
外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。 外加 `/usr/local/bin/ffprobe`:从 `mwader/static-ffmpeg:9.0.2` 拷单个静态二进制(约 135 MB;Debian `ffmpeg` 包要 200+ 个依赖约 450 MB)。
换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。 换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。
- `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。 - `/app` 下的二进制、静态产物、脚本全部 root 属主只读;运行用户改不了它们。
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,/app/umt-server` - 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat,/app/umt-server`
(库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。 (库根/下载目录由用户挂载,位置不固定,所以读写不按路径限制;写保护靠只读 `/app` + 非 root 运行)。
子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, 子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算,
不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身
+1 -1
View File
@@ -139,7 +139,7 @@ curl localhost:4380/api/v1/health
- 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。 - 镜像:`denoland/deno:2.9.7` 构建 web 与 `deno compile` 出二进制,运行时 `debian:trixie-slim` + gosu + postgresql-client + 静态 ffprobe,root 启动修 `/data` 属主后降权到 PUID(缺省 10001)。
- 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api、worker、worker:agent 三个进程;任一退出则停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。 - 入口 `docker/entrypoint.sh`:迁移 → 自检 → 同时起 api、worker、worker:agent 三个进程;任一退出则停掉其余并以非 0 退出,由 `restart: unless-stopped` 拉起。
- 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。 - 二进制编译时固定权限:`--allow-net --allow-env --allow-sys --allow-read --allow-write --allow-run=ffprobe,sha256sum,cat`;镜像带静态 ffprobe;运行期写入落在 `DATA_DIR`(容器内 `/data`)。完整部署说明见 [deploy.md](deploy.md)。
- 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。 - 构建版本号:`APP_VERSION=v0.1.0 docker compose build`(写入 web 的 `version.json`)。
## 测试数据库 ## 测试数据库