From c8b514832f4f9876df624b456e578cb0af1cae8d Mon Sep 17 00:00:00 2001 From: maogeigei Date: Wed, 16 Sep 2026 11:28:50 +0800 Subject: [PATCH] =?UTF-8?q?feat(worker):=20D2=20=E5=BB=BA=E5=8F=B7?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=8C=96=20ensureOsAccount=20+=20D1=20?= =?UTF-8?q?=E8=8A=82=E7=82=B9=E8=87=AA=E4=B8=BE=E8=84=9A=E6=9C=AC=20+=205?= =?UTF-8?q?=20=E4=BE=8B=E6=B5=8B=E8=AF=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- package.json | 2 +- scripts/bootstrap-worker.sh | 184 +++++++++++++++++++++++++++++++++ src/worker/agent.ts | 63 +++++++++++ test/worker-provision.test.mjs | 38 +++++++ 4 files changed, 286 insertions(+), 1 deletion(-) create mode 100644 scripts/bootstrap-worker.sh create mode 100644 test/worker-provision.test.mjs diff --git a/package.json b/package.json index 3cfa1e2..900b869 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,7 @@ "prepare": "npm run build", "dev": "node lib/cli.js", "typecheck": "tsc -p tsconfig.json --noEmit", - "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs", + "verify": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs test/locale-pref.test.mjs test/worker-provision.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js && node scripts/verify-static.mjs && node scripts/verify-platform-admin-section.mjs && node scripts/verify-mem-model.mjs && node scripts/verify-model-landing.mjs && node scripts/verify-dsh-install.mjs && node scripts/verify-models-dict.mjs && node scripts/verify-models-render.cjs && node scripts/verify-dsh-compat.mjs && node scripts/verify-my-skills.mjs && node scripts/verify-portal-entry.mjs", "test": "npm run build && node --test test/db.test.mjs test/local-user-fs.test.mjs test/crash-policy.test.mjs && node scripts/verify-inject.cjs lib/supervisor/proxy.js", "smoke": "node scripts/smoke.mjs", "smoke:admin": "node scripts/smoke-admin.mjs", diff --git a/scripts/bootstrap-worker.sh b/scripts/bootstrap-worker.sh new file mode 100644 index 0000000..e127bc8 --- /dev/null +++ b/scripts/bootstrap-worker.sh @@ -0,0 +1,184 @@ +#!/usr/bin/env bash +# +# bootstrap-worker.sh —— 把一台机器拉到「可承接实例的集群节点」状态 +# +# 立稿 2026-09-16。起因:把 guest 从 w-47 迁到 w-106 时,节点侧缺的东西全是**手工**补的 +# (装 dsh、传 runtime、改目录权限、useradd)—— 用户明确要求「该谁处理就让对应功能处理」。 +# 本脚本就是那个「功能」:**幂等**、可重复执行、只做正向补齐。 +# +# 它覆盖四件套 + 权限 + 自检(自检项刻意包含 `dshs doctor` 漏掉的几项): +# 1. dsh 主程序 —— 缺则按指定版本从 npm 装(内网/镜像优先) +# 2. dsh-runtime —— jq / rg / ffmpeg / ffprobe 走本机包管理(**不要从别的机器搬**) +# python 见 §3 的「运行时路径契约」 +# 3. 数据根与权限 —— /var/lib/dshs 711、users 711(**漏了会让实例必崩,且不报权限错**) +# 4. 旧角色残留 —— 可选(--prune-legacy) +# 5. 自检 —— 四件套 + 权限 + 端口 + 数据根,任一项失败 ⇒ 退出码非 0 +# +# 用法: +# ./bootstrap-worker.sh # 补齐 + 自检 +# ./bootstrap-worker.sh --check # 只自检,不改动任何东西 +# ./bootstrap-worker.sh --prune-legacy # 额外清理已知的旧控制面(停用 + 禁用,不删数据) +# +# 环境变量: +# DSH_VERSION 要安装的 @deepseek-ai/dsh 版本(默认 0.1.5-rc.1,须与 Manager 一致) +# DSHS_DATA_ROOT 数据根(默认 /var/lib/dshs) +# DSHS_RUNTIME_DIR 运行时目录(默认 /usr/local/dsh-runtime) +# +set -uo pipefail + +DSH_VERSION="${DSH_VERSION:-0.1.5-rc.1}" +DATA_ROOT="${DSHS_DATA_ROOT:-/var/lib/dshs}" +RUNTIME_DIR="${DSHS_RUNTIME_DIR:-/usr/local/dsh-runtime}" +CHECK_ONLY=0 +PRUNE_LEGACY=0 +FAILED=0 + +for arg in "$@"; do + case "$arg" in + --check) CHECK_ONLY=1 ;; + --prune-legacy) PRUNE_LEGACY=1 ;; + -h|--help) sed -n '2,25p' "$0"; exit 0 ;; + *) echo "未知参数: $arg" >&2; exit 2 ;; + esac +done + +step() { printf '\n\033[1m== %s ==\033[0m\n' "$1"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } +bad() { printf ' \033[31m✗\033[0m %s\n' "$1"; FAILED=1; } +act() { [ "$CHECK_ONLY" = 1 ] && { warn "(--check)跳过: $1"; return 1; }; return 0; } + +# ---------------------------------------------------------------- 1. 前置 +step "1. 前置条件" +[ "$(id -u)" = "0" ] && ok "以 root 运行" || bad "需要 root(useradd / systemd / chown)" +command -v systemctl >/dev/null && ok "systemd 可用" || bad "缺 systemd" +for c in node npm useradd chown; do + command -v "$c" >/dev/null && ok "命令 $c" || bad "缺命令 $c" +done +command -v nft >/dev/null && ok "nft 可用" || warn "缺 nft(跨机防火墙规则会缺失)" + +# ---------------------------------------------------------------- 2. dsh 主程序 +step "2. dsh 主程序(版本须与 Manager 一致:$DSH_VERSION)" +DSH_BIN="${DSHS_DSH_BIN:-}" +[ -z "$DSH_BIN" ] && DSH_BIN="$(command -v dsh || true)" +if [ -n "$DSH_BIN" ] && [ -x "$DSH_BIN" ]; then + have="$("$DSH_BIN" --version 2>/dev/null | head -1)" + if [ "$have" = "$DSH_VERSION" ]; then + ok "已安装且版本正确: $DSH_BIN ($have)" + else + warn "已装版本 $have ≠ 期望 $DSH_VERSION" + if act "npm i -g @deepseek-ai/dsh@$DSH_VERSION"; then + npm i -g "@deepseek-ai/dsh@$DSH_VERSION" >/dev/null 2>&1 && ok "已升级到 $DSH_VERSION" || bad "安装失败" + fi + fi +else + warn "未安装 dsh" + if act "npm i -g @deepseek-ai/dsh@$DSH_VERSION"; then + npm i -g "@deepseek-ai/dsh@$DSH_VERSION" >/dev/null 2>&1 && ok "已安装" || bad "安装失败" + fi +fi +# 平台按裸命令 `dsh` 解析(DEFAULT_DSH_COMMAND=['dsh'])⇒ 保证 PATH 里能找到 +if command -v dsh >/dev/null; then + ok "PATH 可解析 dsh: $(command -v dsh)" +else + warn "PATH 里找不到 dsh(平台以裸命令启动实例)" + for cand in /usr/bin/dsh /usr/local/bin/dsh; do + [ -x "$cand" ] && { [ "$CHECK_ONLY" = 0 ] && ln -sfn "$cand" /usr/local/bin/dsh && ok "已补 /usr/local/bin/dsh → $cand"; break; } + done +fi + +# ---------------------------------------------------------------- 3. 运行时 +step "3. dsh-runtime(jq / rg / ffmpeg / ffprobe)" +for tool in jq rg ffmpeg ffprobe; do + if command -v "$tool" >/dev/null; then + ok "$tool: $(command -v "$tool")" + else + warn "缺 $tool" + if act "包管理器安装 $tool"; then + # 本机/内网源即可(实测腾讯云内网源 43 MB/s,别从别的机器搬几百 MB) + if command -v dnf >/dev/null; then dnf install -y "$tool" >/dev/null 2>&1 + elif command -v apt-get >/dev/null; then apt-get install -y "$tool" >/dev/null 2>&1 + fi + command -v "$tool" >/dev/null && ok "$tool 已安装" || bad "$tool 安装失败" + fi + fi +done +# 平台约定:实例从 $RUNTIME_DIR/bin 取工具 ⇒ 补契约链接 +if [ -d "$RUNTIME_DIR/bin" ] || [ "$CHECK_ONLY" = 0 ]; then + [ "$CHECK_ONLY" = 0 ] && mkdir -p "$RUNTIME_DIR/bin" + for tool in jq rg ffmpeg ffprobe; do + src="$(command -v "$tool" 2>/dev/null || true)" + if [ -n "$src" ] && [ ! -e "$RUNTIME_DIR/bin/$tool" ]; then + [ "$CHECK_ONLY" = 0 ] && ln -sfn "$src" "$RUNTIME_DIR/bin/$tool" && ok "$RUNTIME_DIR/bin/$tool → $src" + fi + done +fi + +step "3b. python 运行时(路径契约)" +PY="" +for p in "$RUNTIME_DIR"/python-3.12.*/bin/python3.12; do + [ -x "$p" ] && PY="$p" && break +done +if [ -n "$PY" ]; then + ok "python: $PY ($("$PY" -V 2>&1))" +else + warn "未找到 $RUNTIME_DIR/python-3.12.*/bin/python3.12" + warn "⚠️ 迁移用户的 venv 里写死了这个路径(pyvenv.cfg 的 executable)⇒ 缺失则其 Python 环境不可用" + warn "正解:把与 Manager 同版本的 python-build-standalone 放到该路径(整目录可搬)" + FAILED=1 +fi + +# ---------------------------------------------------------------- 4. 数据根与权限 +step "4. 数据根与权限(漏了会让实例必崩,且不报权限错)" +if [ ! -d "$DATA_ROOT" ]; then + if act "创建 $DATA_ROOT"; then mkdir -p "$DATA_ROOT" && ok "已创建 $DATA_ROOT"; fi +else + ok "$DATA_ROOT 存在" +fi +# 关键:users 必须 o+x(711),否则 setpriv --reuid 无法穿越 ⇒ 实例起不来 +for d in "$DATA_ROOT" "$DATA_ROOT/users"; do + [ -d "$d" ] || { [ "$CHECK_ONLY" = 0 ] && mkdir -p "$d"; } + [ -d "$d" ] || continue + mode="$(stat -c '%a' "$d")" + if [ "$mode" = "711" ]; then + ok "$(printf '%-28s' "$d") 权限 711" + else + warn "$(printf '%-28s' "$d") 权限 $mode ≠ 711" + if act "chmod 711 $d"; then chmod 711 "$d" && ok "已改为 711"; fi + fi +done + +# ---------------------------------------------------------------- 5. 旧角色清理 +step "5. 旧角色残留(可选)" +LEGACY_UNITS="dsh-users-platform.service" +found_legacy=0 +for u in $LEGACY_UNITS; do + if systemctl list-unit-files "$u" >/dev/null 2>&1 && systemctl list-unit-files | grep -q "^$u"; then + found_legacy=1 + if [ "$PRUNE_LEGACY" = 1 ]; then + if act "停用 $u"; then + systemctl stop "$u" >/dev/null 2>&1; systemctl disable "$u" >/dev/null 2>&1 && ok "已停用并禁用 $u(数据未删)" + fi + else + warn "$u 仍在(用 --prune-legacy 停用;本节点若已切角色为纯 worker 才该执行)" + fi + fi +done +[ "$found_legacy" = 0 ] && ok "未发现已知旧角色单元" + +# ---------------------------------------------------------------- 6. 自检汇总 +step "6. 自检汇总" +rc=0 +{ command -v dsh >/dev/null && [ -x "$(command -v dsh)" ]; } || { bad "dsh 不可执行"; rc=1; } +[ -n "$PY" ] || { bad "python 运行时缺失"; rc=1; } +for tool in jq rg ffmpeg ffprobe; do command -v "$tool" >/dev/null || { bad "缺 $tool"; rc=1; }; done +[ "$(stat -c '%a' "$DATA_ROOT/users" 2>/dev/null)" = "711" ] || { bad "$DATA_ROOT/users 权限非 711"; rc=1; } +command -v setpriv >/dev/null || { bad "缺 setpriv"; rc=1; } +command -v bwrap >/dev/null || warn "缺 bwrap(隔离靠它;补齐前不要承接实例)" + +if [ "$rc" = 0 ] && [ "$FAILED" = 0 ]; then + printf '\n\033[32m节点自检通过:可以承接实例。\033[0m\n' +else + printf '\n\033[31m节点未就绪(上面 ✗ 项必须解决)。\033[0m\n' +fi +exit $(( rc || FAILED )) diff --git a/src/worker/agent.ts b/src/worker/agent.ts index 674ae91..349e956 100644 --- a/src/worker/agent.ts +++ b/src/worker/agent.ts @@ -18,7 +18,9 @@ * * @module dshs/worker/agent */ +import { execFileSync } from 'node:child_process' import { timingSafeEqual } from 'node:crypto' +import { existsSync } from 'node:fs' import Fastify, { type FastifyInstance, type FastifyReply } from 'fastify' import type { ServerConfig } from '../config.js' import { LocalUserFs } from '../fs/local-user-fs.js' @@ -61,6 +63,57 @@ const OP_CACHE_MAX = 512 /** patch 内容长度上限(防把 agent 当大对象存储)。 */ const MAX_PATCH_BYTES = 256 * 1024 +/** 按 uid 查 passwd 条目(查不到返回 undefined)。 */ +function passwdEntryForUid(uid: number): string | undefined { + try { + const out = execFileSync('getent', ['passwd', String(uid)], { encoding: 'utf8' }).trim() + return out === '' ? undefined : out + } catch { + return undefined + } +} + +/** + * 确保该用户在本机的 OS 账号存在(幂等)—— **集群化后「建号」的责任落点**(2026-09-16 定)。 + * + * **为什么必须落在这里** + * - 账号是**机器本地状态**(`/etc/passwd`),而 uid 由 Manager 从控制面库分配、随 + * `POST /launch` 投递 ⇒ **只有 agent 同时握有「uid」与「这台机器」**。 + * - 单机时代的 `dsh-provision.path`(观察目录出现 ⇒ 建号)靠本地控制面库算 uid, + * 集群下算不出来:实测 `hashUid` 兜底把 guest 算成 **184656**(真实 100002), + * 而 PG 是 Manager 专属(Worker 不连控制面库,见本文件头「边界」)⇒ + * **不能把建号留在 Worker 本地被动触发**。 + * + * **规格**与 `provision-new-users.sh` 一致(`dsh-<短id>` / `-M` / `nologin` / 同 uid), + * 因此与存量节点兼容且可重复执行(幂等)。 + * + * ⚠️ 这是**受控固定动作**,不接受调用方传命令或路径 ⇒ 不违反「最小接口」纪律: + * `userId` 必须是 UUID 形状、`uid` 必须是 `>= baseUid` 的整数。 + */ +export function ensureOsAccount(config: ServerConfig, userId: string, uid: number): void { + // ① 参数校验先行(与平台无关):非法输入在任何平台都该被拒绝 + if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(userId)) { + throw new Error(`ensureOsAccount: invalid userId ${userId}`) + } + if (!Number.isInteger(uid) || uid < config.baseUid) { + throw new Error(`ensureOsAccount: invalid uid ${uid} (baseUid=${config.baseUid})`) + } + // ② 非 Linux 节点没有 useradd 语义(客户端节点形态另议) + if (process.platform !== 'linux') return + // ⚠️ 判定**只看 uid**:uid 由 Manager 全局唯一分配 ⇒「同 uid」即「同一用户」, + // 因此**不校验账号名**。存量账号存在历史命名差异(实测 47 上 guest 的账号是 + // `dsh-eeccbc638afc46bdb663`,而按规则本该是 `dsh-4092b9652f6849779989`)—— + // 名字只是本机标识,权限判据始终是 uid;为它改名反而要动 /etc/passwd + 全量 chown。 + const entry = passwdEntryForUid(uid) + if (entry === undefined) { + const name = `dsh-${userId.replace(/-/g, '').slice(0, 20)}` + execFileSync('useradd', ['-u', String(uid), '-M', '-s', '/usr/sbin/nologin', name], { stdio: 'pipe' }) + } + // 目录已存在则对齐属主(目录可能尚未创建 ⇒ 留给 spawner 建) + const root = userRoot(config.dataRoot, userId) + if (existsSync(root)) execFileSync('chown', ['-R', `${uid}:${uid}`, root], { stdio: 'pipe' }) +} + interface OpCache { order: string[] results: Map @@ -229,6 +282,16 @@ export function buildWorkerAgent( if (body.apiKey !== undefined && body.apiKey !== null) apiKeys.set(body.userId, body.apiKey) if (body.uid !== undefined) uids.set(body.userId, body.uid) if (body.epoch !== undefined) epochs.set(body.userId, body.epoch) + // 建号必须先于 spawn:OS 账号缺席时 bwrap 里的 `setpriv --reuid` 会直接失败 + // (现象是"实例起不来",不会报权限错)。幂等:已建过只对账属主;uid 被他人占用则 fail-loud。 + if (body.uid !== undefined) { + try { + ensureOsAccount(config, body.userId, body.uid) + } catch (err) { + const why = err instanceof Error ? err.message : String(err) + return reply.code(500).send({ error: `provision-failed: ${why}` }) + } + } try { const instance = await spawner.launch(body.userId, body.folder ?? '', body.patch) // 跨机:把该实例端口经隧道打到 Manager 侧(失败不阻断 —— 本机仍可用) diff --git a/test/worker-provision.test.mjs b/test/worker-provision.test.mjs new file mode 100644 index 0000000..c5939c0 --- /dev/null +++ b/test/worker-provision.test.mjs @@ -0,0 +1,38 @@ +/** + * `ensureOsAccount`(`src/worker/agent.ts`)—— 集群化后「建号」落点的守卫。 + * + * 本套件只覆盖**参数校验**(纯逻辑,任何平台都能跑)。 + * 真实建号(`useradd` + `chown`)需要 Linux root ⇒ 在各节点上冒烟验证,不进本套件。 + * + * 背景:单机时代的 `dsh-provision.path` 靠**本地控制面库**算 uid,集群下算不出来 + * (实测 `hashUid` 兜底会把 guest 算成 184656 ≠ 真实 100002,而 PG 是 Manager 专属) + * ⇒ 改为「Manager 随 `POST /launch` 投递 uid,agent 在 spawn 前确保账号存在」。 + * 见 `agent.ts` 中该函数的注释。 + */ +import test from 'node:test' +import assert from 'node:assert/strict' +import { ensureOsAccount } from '../lib/worker/agent.js' + +const config = { baseUid: 100000, dataRoot: '/nonexistent-dshs-root' } +const VALID = '4092b965-2f68-4977-9989-68b3966f7df0' + +test('ensureOsAccount:拒绝非 UUID 形状的 userId', () => { + assert.throws(() => ensureOsAccount(config, 'guest', 100002), /invalid userId/) +}) + +test('ensureOsAccount:拒绝低于 baseUid 的 uid', () => { + assert.throws(() => ensureOsAccount(config, VALID, 999), /invalid uid/) +}) + +test('ensureOsAccount:拒绝非整数 uid', () => { + assert.throws(() => ensureOsAccount(config, VALID, 100000.5), /invalid uid/) +}) + +test('ensureOsAccount:拒绝负数 uid', () => { + assert.throws(() => ensureOsAccount(config, VALID, -1), /invalid uid/) +}) + +test('ensureOsAccount:非 Linux 平台在参数合法时静默返回(不抛错、不建号)', () => { + if (process.platform === 'linux') return // 该用例只在非 Linux 上有意义 + assert.doesNotThrow(() => ensureOsAccount(config, VALID, 100002)) +})