diff --git a/apps/server/deno.json b/apps/server/deno.json index dffb8bbe..d8ec186a 100644 --- a/apps/server/deno.json +++ b/apps/server/deno.json @@ -4,8 +4,8 @@ "exports": { ".": "./src/main.ts" }, "tasks": { "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", - "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", + "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,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", "typecheck": "deno run -A npm:typescript/tsc --noEmit -p tsconfig.json" } diff --git a/apps/server/src/modules/system/resources.ts b/apps/server/src/modules/system/resources.ts index a6f17796..543b27d6 100644 --- a/apps/server/src/modules/system/resources.ts +++ b/apps/server/src/modules/system/resources.ts @@ -1,6 +1,7 @@ import { availableParallelism, cpus } from "node:os"; import process from "node:process"; import type { ResourceSample, ResourceScope, SystemResources } from "@umt/contracts"; +import { readProcText } from "../../proc-read.ts"; // 资源监控(界面第二轮 V 线,参考 MP 仪表盘 CPU / 内存 / 网络组件):api 进程每 5 秒读一次 /proc 与 cgroup v2, // 相邻两次读数的差算速率,采样只放内存(环形,保留 6 小时)。读不到的文件按 null 降级,不报错。 @@ -14,13 +15,8 @@ const MAX_POINTS = 360; export type ReadText = (path: string) => Promise; -export const defaultReadText: ReadText = async (path) => { - try { - return await Deno.readTextFile(path); - } catch { - return null; - } -}; +/** /proc 与 /sys 需特权读,编译产物走 cat 兜底(见 proc-read.ts) */ +export const defaultReadText: ReadText = readProcText; // ── 解析(纯函数) ── diff --git a/apps/server/src/proc-read.ts b/apps/server/src/proc-read.ts new file mode 100644 index 00000000..66f13d75 --- /dev/null +++ b/apps/server/src/proc-read.ts @@ -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 { + 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 { + 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; + } +} diff --git a/apps/server/src/sandbox/client.ts b/apps/server/src/sandbox/client.ts index d3e11569..37c72d3b 100644 --- a/apps/server/src/sandbox/client.ts +++ b/apps/server/src/sandbox/client.ts @@ -3,6 +3,7 @@ // 起不来(权限 / 运行时不支持)时退回进程内执行并记日志:仍有超时,但卡死的同步代码无法收回(只在本机调试时出现)。 import { basename } from "node:path"; import { fileURLToPath } from "node:url"; +import { readProcText } from "../proc-read.ts"; import { prepareJobs, SANDBOX_JOBS, type SandboxInput, type SandboxJobName, type SandboxOutput } from "./jobs.ts"; export type SandboxErrorKind = "timeout" | "memory" | "crash" | "job" | "spawn"; @@ -52,13 +53,9 @@ function command(memoryMb: number): { cmd: string; args: string[] } { /** 子进程实际内存(KB);读不到返回 null */ async function rssKb(pid: number): Promise { - try { - const s = await Deno.readTextFile(`/proc/${pid}/status`); - const m = /VmRSS:\s+(\d+)\s+kB/.exec(s); - return m ? Number(m[1]) : null; - } catch { - return null; - } + const s = await readProcText(`/proc/${pid}/status`); + const m = s ? /VmRSS:\s+(\d+)\s+kB/.exec(s) : null; + return m ? Number(m[1]) : null; } export async function runSandbox( diff --git a/apps/server/test/system/proc-read.test.ts b/apps/server/test/system/proc-read.test.ts new file mode 100644 index 00000000..ad3b416e --- /dev/null +++ b/apps/server/test/system/proc-read.test.ts @@ -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); +}); diff --git a/docs/adr/ADR-004-deno-runtime.md b/docs/adr/ADR-004-deno-runtime.md index 12d839d2..852cae5e 100644 --- a/docs/adr/ADR-004-deno-runtime.md +++ b/docs/adr/ADR-004-deno-runtime.md @@ -178,5 +178,5 @@ deno task ci # typecheck(tsc 7.0.2 + deno check)→ biome → vitest 讨论过换 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`)。 diff --git a/docs/plan/lessons.md b/docs/plan/lessons.md index 72e483b3..f2371df5 100644 --- a/docs/plan/lessons.md +++ b/docs/plan/lessons.md @@ -46,3 +46,4 @@ 29. 两路各自合理的改动合起来出问题:B 路以「种子被占用」取消计划,C 路让「取消的计划立即让位」,合并后会取消 → 重评 → 再取消空转 → **规则**:协调者合并时专门看跨线交互(状态取值、原因码、让位 / 重试规则),补一条跨线测试。 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`)。 diff --git a/docs/runbook/deploy.md b/docs/runbook/deploy.md index be861f02..a9cc6c41 100644 --- a/docs/runbook/deploy.md +++ b/docs/runbook/deploy.md @@ -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)。 换来源用 `--build-arg FFPROBE_IMAGE=<含 /ffprobe 的镜像>`。 - `/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 运行)。 子进程只放三个:ffprobe(入库后探测视频流)、sha256sum(coreutils 自带;目标已存在/跨卷恢复时的全量哈希在子进程里算, 不占 worker 事件循环;不可用时回退进程内流式哈希,整理结果里 `hashVia` 记录实际方式)、二进制自身 diff --git a/docs/runbook/dev.md b/docs/runbook/dev.md index 955a6c5f..ad1df34c 100644 --- a/docs/runbook/dev.md +++ b/docs/runbook/dev.md @@ -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)。 - 入口 `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`)。 ## 测试数据库