feat(worker): D2 建号自动化 ensureOsAccount + D1 节点自举脚本 + 5 例测试
This commit is contained in:
1 parent
500011d329
commit
c8b514832f
4 files changed
+286
-1
No files matched your search
+1
-1
@@ -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",
|
||||
|
||||
@@ -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 ))
|
||||
@@ -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 侧(失败不阻断 —— 本机仍可用)
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
Reference in new issue
Block a user