Files
dsh_shenxian/scripts/verify-cluster-cross.mjs
T
admin c70d5d860e feat(cluster): 集群化落地 —— Manager/Worker 拆分 + 归属租约 + 跨机验证(T08)
背景:把平台从「单机单进程」改造成「1 组 Manager + N 台 Worker + 共享归属状态」,
硬约束 = 全程兼容单例模式(deployMode 默认 local;生产切换前 47 一行未动)。

主要改动
1) 数据模型 v7(SQLite 与 PG 两方言同步):新增 dsh_hosts 注册表 +
   dsh_instances.{host_id,epoch,heartbeat_at,lease_until};claimInstance 原子抢占
   (UPDATE … WHERE host_id IS NULL OR lease_until < now)+ pinInstanceHost 钉住归属。
2) 租约与 fencing:src/supervisor/lease.ts(acquire/renew/release + stillHolder 判据 +
   ttl > 2×renew 硬校验);心跳里续租,失权即向 worker 下发更高 epoch(self-fencing)。
   ⚠️ release 只清租约(lease_until),**保留 host_id** —— host_id 是「用户数据在哪台」的锚点。
3) Worker agent(src/worker/agent.ts,子命令 dshs worker):实例生命周期 + 文件面 /fs/*
   + 幂等键(operationId)+ 鉴权(timingSafeEqual);Worker 不写控制面数据
   (apiKey/uid 由 Manager 随 launch 投递,R5 收窄)。
4) 远端 Spawner + LeasedSpawner:按 host 路由(**粘性优先**:有历史归属且那台 up 就留在原地,
   否则按容量选最空的)+ 容量准入 + deployMode=cluster 装配(systemd drop-in,可回滚)。
5) bwrap 修正:**所有挂载点的中间目录统一前置 + 去重 + 由外到内**(「就近创建」会在嵌套前缀下
   遮掉已绑挂载点 ⇒ bwrap: Can't chdir);且**只能用 --tmpfs**,用 --perms 会让 47 的
   bwrap 0.4.0 直接拒启动(沙箱全挂)。
6) 跨机隧道 src/worker/tunnel.ts:SSH ControlMaster + 动态 -R 转发;**自愈由 agent 本地
   20s 定时器驱动**(不能只放 /healthz —— 心跳本身经隧道进来,断了就没人触发它)。
7) 文件面按归属路由(RemoteUserFs):实例与文件必须落在同一台机器,否则实例看不到自己的文件。
8) 观测面:dshs doctor / dshs cluster status。

验证(本次均已实跑)
- test/lease.test.mjs:SQLite 10/10 == PG 10/10
- 组件级端到端 5 个:verify-cluster-{agent,lease,fs,migrate,live}.mjs
- 真跨机(47 Manager / 106 Worker,跨云 + 反向隧道)verify-cluster-cross.mjs 九步全绿
- 域名形态访问 verify-cluster-domain.mjs(<user>.域名 → Manager → 远端实例;越权 403)
- 冒烟 scripts/smoke-*:6/8,失败项与改动前基线完全相同(无回归)
- 生产切换与回滚剧本见 dsh-server-docs/交接单/T08-集群化落地-兼容单例模式.md §16
2026-09-15 18:47:02 +08:00

185 lines
10 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* T08 · **真跨机演练**驱动脚本(在 Manager 那台机器上运行)。
*
* 与 `verify-cluster-live.mjs`(同机、脚本自己起进程)的区别:这里**假设两侧都已部署好**:
* · Manager 运行在**本机**(47)`http://127.0.0.1:13080`
* · Worker agent 运行在**另一台机器**(106),经 **SSH 反向隧道**出现在本机 `127.0.0.1:19000`
* · 控制面 PG 也在**另一台机器**(106)上,经隧道出现在本机 `127.0.0.1:15432`
* 它回答的是本次演练的核心问题:**跨机到底能不能用**(含跨机代理取页面、跨 worker 迁移)。
*
* 运行(在 47 上):MANAGER=http://127.0.0.1:13080 AGENT_TOKEN=cross-machine-token \
* AGENT2=http://127.0.0.1:19001 node scripts/verify-cluster-cross.mjs
*/
const MANAGER = process.env.MANAGER ?? 'http://127.0.0.1:13080'
const TOKEN = process.env.AGENT_TOKEN ?? 'cross-machine-token'
const AGENT1 = process.env.AGENT1 ?? 'http://127.0.0.1:19000'
const AGENT2 = process.env.AGENT2 ?? ''
const ADMIN_PW = process.env.ADMIN_PW ?? 'crossmgr123'
const USER_PW = 'crossuser123'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const json = async (path, { method = 'GET', body, cookie } = {}) => {
const res = await fetch(MANAGER + path, {
method,
headers: { ...(body ? { 'content-type': 'application/json' } : {}), ...(cookie ? { cookie } : {}) },
body: body ? JSON.stringify(body) : undefined,
signal: AbortSignal.timeout(30_000),
})
const text = await res.text()
let parsed = null
try {
parsed = text === '' ? null : JSON.parse(text)
} catch {
parsed = { raw: text.slice(0, 200) }
}
return { status: res.status, body: parsed, setCookie: res.headers.get('set-cookie') }
}
/** 直接问 worker(绕过 Manager)—— 证明实例真的落在**那台机器**上。 */
const agent = async (base, path) => {
const res = await fetch(base + path, { headers: { 'x-dsh-agent-token': TOKEN }, signal: AbortSignal.timeout(10_000) })
const text = await res.text()
return { status: res.status, body: text === '' ? null : JSON.parse(text) }
}
/** 取页面:跟随重定向(dsh 首页 303),并对启动窗口的断连做重试。 */
async function fetchPage(url, cookie, tries = 40) {
let status = 0
let snippet = ''
for (let i = 0; i < tries; i += 1) {
try {
const res = await fetch(MANAGER + url, { headers: { cookie }, redirect: 'follow', signal: AbortSignal.timeout(20_000) })
status = res.status
if (res.status === 200) {
snippet = (await res.text()).slice(0, 200)
break
}
} catch {
status = 0
}
await sleep(1000)
}
return { status, snippet }
}
async function waitRunning(cookie, tries = 60) {
for (let i = 0; i < tries; i += 1) {
const st = await json('/api/dsh/status', { cookie })
if (st.body?.running === true) return st.body
if (st.body?.instance?.status === 'crashed') return st.body
await sleep(1000)
}
return await json('/api/dsh/status', { cookie }).then((r) => r.body)
}
try {
console.log('=== 跨机演练:Manager=%s Worker=%s ===', MANAGER, AGENT1)
// ── 0) 两侧可达性(跨机链路的第一层证据)─────────────────────────────
const h1 = await agent(AGENT1, '/healthz')
assert(h1.status === 200 && h1.body.hostId === 'w-106', `Worker w-106 应可达(实际 ${JSON.stringify(h1.body)})`)
assert(h1.body.tunnel?.ready === true, `Worker 侧隧道应就绪(实际 ${JSON.stringify(h1.body.tunnel)})`)
console.log('⓪ worker 可达 -> %s(隧道 ready,已转发 %s)', h1.body.hostId, JSON.stringify(h1.body.tunnel.ports))
// ── 1) 管理面:注册 worker(join 脚本干的事)──────────────────────────
const adm = await json('/api/auth/login', { method: 'POST', body: { username: 'root', password: ADMIN_PW } })
assert(adm.status === 200, `管理员登录失败 ${adm.status}`)
const adminCookie = adm.setCookie.split(';')[0]
let r = await json('/api/admin/hosts', {
method: 'POST',
cookie: adminCookie,
body: { id: 'w-106', endpoint: AGENT1, token: TOKEN, capacityMb: 4096 },
})
assert(r.status === 200, `注册 w-106 失败 ${r.status}`)
const hosts = await json('/api/admin/hosts', { cookie: adminCookie })
assert(hosts.body.hosts.some((h) => h.id === 'w-106'), 'w-106 出现在 worker 目录')
assert(!('agentToken' in (hosts.body.hosts[0] ?? {})), '**绝不下发 agentToken**')
console.log('① 注册 -> w-106(列表不含 agentToken)')
// ── 2) 用户流程 ───────────────────────────────────────────────────────
const uname = `crossuser${Date.now() % 100000}`
r = await json('/api/auth/register', { method: 'POST', body: { username: uname, password: USER_PW } })
assert(r.status === 201, `注册应 201(实际 ${r.status})`)
const users = await json('/api/admin/users', { cookie: adminCookie })
const target = users.body.users.find((u) => u.username === uname)
assert(target !== undefined, '管理员能看到待审用户')
r = await json(`/api/admin/users/${target.id}/approve`, { method: 'POST', cookie: adminCookie })
assert(r.status === 200, `审批应 200(实际 ${r.status})`)
const login = await json('/api/auth/login', { method: 'POST', body: { username: uname, password: USER_PW } })
assert(login.status === 200, `用户登录失败 ${login.status}`)
const cookie = login.setCookie.split(';')[0]
console.log('② 用户流程 -> 注册→审批→登录(uid=%s)', target.id)
// ── 3) 文件面跨机(Manager 在 47、目录落在 106)───────────────────────
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj' } })
assert(r.status === 200, `mkdir 应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
console.log('③ 文件面 -> mkdir 经隧道落到 106 的 worker')
// ── 4) 拉起实例(真 dsh 在 **106** 上)────────────────────────────────
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
assert(r.status === 200, `launch 应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
const st = await waitRunning(cookie)
assert(st?.running === true, `实例应 running(实际 ${JSON.stringify(st)?.slice(0, 300)})`)
const onAgent = await agent(AGENT1, '/instances')
assert(onAgent.body.instances.length === 1, 'worker(106) 上有 1 个实例')
console.log('④ 拉起 -> running=true,**实例在 106 上**(worker /instances=%d)', onAgent.body.instances.length)
// ── 5) 登录直达 + **跨机取页面**(本演练的核心证据)───────────────────
const enter = await json('/api/dsh/enter', { method: 'POST', cookie })
assert(enter.status === 200, `enter 应 200(实际 ${enter.status})`)
const url = enter.body.url
assert(typeof url === 'string' && url.includes('token='), `enter 应带 token(实际 ${url})`)
const page = await fetchPage(url, cookie)
assert(page.status === 200, `**跨机页面**应 200(实际 ${page.status})`)
console.log('⑤ 跨机页面 -> 200(47 的 Manager 代理到 106 的实例;片段 %s)', page.snippet.replace(/\s+/g, ' ').slice(0, 60))
// ── 6) 第二台 worker(106 上模拟的第二台服务器)+ 迁移 ────────────────
if (AGENT2 !== '') {
const h2 = await agent(AGENT2, '/healthz')
assert(h2.status === 200, `第二台 worker 应可达(实际 ${h2.status})`)
r = await json('/api/admin/hosts', {
method: 'POST',
cookie: adminCookie,
body: { id: 'w-106b', endpoint: AGENT2, token: TOKEN, capacityMb: 4096 },
})
assert(r.status === 200, `注册 w-106b 失败 ${r.status}`)
console.log('⑥ 第二台 -> %s(模拟的第二台服务器)已注册', h2.body.hostId)
r = await json(`/api/admin/users/${target.id}/dsh/migrate`, {
method: 'POST',
cookie: adminCookie,
body: { targetHost: 'w-106b' },
})
assert(r.status === 200, `迁移应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
assert(r.body.to === 'w-106b', `迁移目标应为 w-106b(实际 ${r.body.to})`)
await waitRunning(cookie)
const a1 = await agent(AGENT1, '/instances')
const a2 = await agent(AGENT2, '/instances')
assert(a1.body.instances.length === 0 && a2.body.instances.length === 1, '实例应从 w-106 移到 w-106b')
console.log('⑦ 跨机迁移 -> %s → %s(epoch=%d),源机已空、目标机有 1 个实例', r.body.from, r.body.to, r.body.epoch)
const enter2 = await json('/api/dsh/enter', { method: 'POST', cookie })
assert(enter2.status === 200 && enter2.body.url !== url, '迁移后 enter 应给**新** URL')
const page2 = await fetchPage(enter2.body.url, cookie)
assert(page2.status === 200, `迁移后页面应 200(实际 ${page2.status})`)
console.log('⑧ 迁移后 -> 新 token URL 页面 200')
} else {
console.log('⑥⑦⑧ 跳过(未提供 AGENT2)')
}
// ── 9) 收尾 ───────────────────────────────────────────────────────────
r = await json('/api/dsh/stop', { method: 'POST', cookie })
assert(r.status === 200, `stop 应 200(实际 ${r.status})`)
console.log('⑨ 停止 -> ok')
console.log('\nOK: **真跨机**(47 当 Manager / 106 当 Worker,隧道跨界)演练通过')
console.log(' ✓ worker 可达 ✓ 注册 ✓ 用户流程 ✓ 文件面跨机 ✓ 实例在 106 ✓ 跨机取页面 ✓ 跨 worker 迁移')
} finally {
/* 不主动清理:实例由调用方决定留或停(演练后要观察现场) */
}