feat(worker): D2 建号自动化 ensureOsAccount + D1 节点自举脚本 + 5 例测试

This commit is contained in:
admin committed 2026-09-16 11:28:50 +08:00
1 parent 500011d329
commit c8b514832f
4 files changed
+286 -1

No files matched your search

+1 -1
View File
@@ -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",
+184
View File
@@ -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 <uid> 无法穿越 ⇒ 实例起不来
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 ))
+63
View File
@@ -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<string, unknown>
@@ -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 侧(失败不阻断 —— 本机仍可用)
+38
View File
@@ -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))
})