Files
dsh_shenxian/scripts/verify-cluster-live.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

366 lines
19 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 · **真实部署**端到端功能确认(不是单进程内测试)。
*
* 与 `verify-cluster-*.mjs` 的区别(那些是**组件级**验证,两个 Fastify 跑在同一进程里):
* 这里**真的起进程** —— 2 个 `dshs worker` agent + 1 个 Manager 都是独立进程,
* 经**真 HTTP**(127.0.0.1 端口)与**真 PG** 通信,实例是**真 `dsh` 子进程**(account 隔离)。
* 它回答的是最后一个问题:**这套东西按部署形态装起来,到底能不能用。**
*
* 检查链路(一条真实的用户路径):
* ① bootstrap-admin → ③ register → approve(平台现有流程)
* ④ 登录 → ⑤ 建文件夹(经 RemoteUserFs 落到 worker)→ ⑥ launch(经 agent 起真 dsh)
* ⑦ 轮询 status → ⑧ **取实例页面(经代理)**← 功能确认的关键一步
* ⑨ stop → ⑩ 起第二台 worker → 注册 → **迁移** → 再取一次页面
* ⑪ `dshs doctor` / `dshs cluster status`(观测面)
*
* 需要:106 上 PG 已在 127.0.0.1:15432;以 root 运行(account 隔离要 setpriv/systemd-run)。
* 运行:CLUSTER_LIVE_PG_URL=postgres://dshs:[email protected]:15432/dshs_live node scripts/verify-cluster-live.mjs
*/
import { spawn, spawnSync } from 'node:child_process'
import { createWriteStream, mkdirSync, readFileSync, rmSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
function assert(condition, message) {
if (!condition) throw new Error('ASSERT: ' + message)
}
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
const PG_URL = process.env.CLUSTER_LIVE_PG_URL
if (PG_URL === undefined || PG_URL === '') {
console.error('需要 CLUSTER_LIVE_PG_URL')
process.exit(2)
}
const here = dirname(fileURLToPath(import.meta.url))
const repoRoot = join(here, '..')
const CLI = join(repoRoot, 'lib', 'cli.js')
const TOKEN = 'live-cluster-agent-token'
const DATA_ROOT = process.env.CLUSTER_LIVE_DATA_ROOT ?? '/opt/dshs-cluster/live-data'
const ISO = process.env.CLUSTER_LIVE_ISOLATION ?? 'account'
const M_PORT = Number(process.env.CLUSTER_LIVE_MANAGER_PORT ?? 13080)
const A1_PORT = Number(process.env.CLUSTER_LIVE_AGENT1_PORT ?? 19000)
const A2_PORT = Number(process.env.CLUSTER_LIVE_AGENT2_PORT ?? 19001)
const ADMIN_PW = 'liveadmin123'
const USER_PW = 'liveuser123'
const procs = []
/** 起一个子进程并记下来(收尾统一 SIGTERM ⇒ agent 会先 teardown 实例再退出)。 */
function run(label, args, env) {
const child = spawn(process.execPath, args, {
cwd: repoRoot,
env: { ...process.env, ...env },
stdio: ['ignore', 'pipe', 'pipe'],
})
// ⚠️ **必须留日志**:不留就只能看到"实例没了"而看不到为什么(2026-09-15 实测踩到)
const logPath = `/tmp/live-${label}.log`
const stream = createWriteStream(logPath, { flags: 'w' })
child.stdout.pipe(stream)
child.stderr.pipe(stream)
child.on('exit', (code) => {
if (code !== null && code !== 0 && !stopping) console.error(`[${label}] 提前退出 code=${code}(日志 ${logPath})`)
})
procs.push({ label, child, logPath })
return child
}
/** 打印某个子进程日志的尾部(诊断用)。 */
function tailLog(label, lines = 12) {
const found = procs.find((p) => p.label === label)
if (found === undefined) return
try {
const text = readFileSync(found.logPath, 'utf8').trimEnd().split('\n')
console.error(` ── ${label} 日志尾部 ──`)
for (const line of text.slice(-lines)) console.error(' ', line.slice(0, 200))
} catch {
/* 没日志就算了 */
}
}
let stopping = false
function shutdownAll() {
stopping = true
for (const { child } of procs) {
try {
child.kill('SIGTERM')
} catch {
/* 已退出 */
}
}
}
/** 轮询等一个 URL 可用。 */
async function waitHttp(url, timeoutMs, what) {
const deadline = Date.now() + timeoutMs
let last = ''
while (Date.now() < deadline) {
try {
const res = await fetch(url, { signal: AbortSignal.timeout(2000) })
if (res.status < 500) return res.status
last = `HTTP ${res.status}`
} catch (err) {
last = err instanceof Error ? err.message : String(err)
}
await sleep(300)
}
throw new Error(`等待 ${what} 超时(${url}):${last}`)
}
const base = `http://127.0.0.1:${M_PORT}`
const json = async (path, { method = 'GET', body, cookie } = {}) => {
const res = await fetch(base + 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') }
}
try {
console.log('=== 真实部署检查:dataRoot=%s 隔离=%s ===', DATA_ROOT, ISO)
rmSync(DATA_ROOT, { recursive: true, force: true })
mkdirSync(DATA_ROOT, { recursive: true })
// 干净的 PG 库(本检查专用)
const psql = (sql) =>
spawnSync('su', ['-', 'postgres', '-c', `/usr/bin/psql -p 15432 -q -c "${sql}"`], { encoding: 'utf8' })
psql('DROP DATABASE IF EXISTS dshs_live')
psql('CREATE DATABASE dshs_live OWNER dshs')
console.log('PG -> dshs_live 已重建')
// ── ① worker agent(独立进程)─────────────────────────────────────────
const agentEnv = { DSHS_DATA_ROOT: DATA_ROOT, DSHS_ISOLATION_MODE: ISO }
run('agent-w-1', [CLI, 'worker', '--token', TOKEN, '--port', String(A1_PORT), '--host', '127.0.0.1', '--host-id', 'w-1', '--instance-host', '127.0.0.1', '--log-level', 'warn'], agentEnv)
await waitHttp(`http://127.0.0.1:${A1_PORT}/healthz`, 15_000, 'agent w-1')
console.log('worker w-1 -> http://127.0.0.1:%d 就绪', A1_PORT)
// ── ② Manager(独立进程,cluster 模式)────────────────────────────────
const managerEnv = {
DSHS_DEPLOY_MODE: 'cluster',
DSHS_DB_URL: PG_URL,
DSHS_DATA_ROOT: DATA_ROOT,
DSHS_CLUSTER_HOST_ID: 'm-1',
DSHS_CLUSTER_AGENT_URL: `http://127.0.0.1:${A1_PORT}`,
DSHS_CLUSTER_AGENT_TOKEN: TOKEN,
DSHS_CLUSTER_INSTANCE_HOST: '127.0.0.1',
DSHS_CLUSTER_WORKER_DATA_ROOT: DATA_ROOT,
DSHS_CLUSTER_CAPACITY_MB: '-1', // Manager 自己**不承载实例**
DSHS_CLUSTER_REGISTER_SELF: '0', // 专用 Manager ⇒ **不自注册**(一个 agent 只应有一条 host 记录)
DSHS_CLUSTER_LEASE_TTL_MS: '30000',
}
run('manager', [CLI, '--port', String(M_PORT), '--host', '127.0.0.1', '--log-level', 'warn'], managerEnv)
await waitHttp(`${base}/login.html`, 20_000, 'Manager')
console.log('manager -> %s 就绪(deployMode=cluster)', base)
// ── ③ bootstrap-admin(首次建管理员)──────────────────────────────────
const boot = spawnSync(process.execPath, [CLI, 'bootstrap-admin', '--username', 'root', '--password', ADMIN_PW], {
cwd: repoRoot,
// 用 local 模式初始化管理员 root:它就是这台机上的目录,与 worker 用同一个 DATA_ROOT
env: { ...process.env, DSHS_DATA_ROOT: DATA_ROOT, DSHS_DB_URL: PG_URL },
encoding: 'utf8',
})
assert(boot.status === 0, `bootstrap-admin 失败:${boot.stderr?.slice(0, 300)}`)
console.log('管理员 -> root 已创建(bootstrap-admin)')
// ── ④ 真实用户流程:注册 → 审批 → 登录 ────────────────────────────────
let 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/auth/register', { method: 'POST', body: { username: 'liveuser', password: USER_PW } })
assert(r.status === 201, `注册应 201(实际 ${r.status} ${JSON.stringify(r.body)})`)
const users = await json('/api/admin/users', { cookie: adminCookie })
const target = users.body.users.find((u) => u.username === 'liveuser')
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: 'liveuser', password: USER_PW } })
assert(login.status === 200, `用户登录失败:${login.status}`)
const cookie = login.setCookie.split(';')[0]
console.log('① 用户流程 -> 注册 → 审批 → 登录 全部通过(uid=%s)', target.id)
// 显式注册 w-1(= join 脚本那一步:agent 已在跑,调管理面登记)
r = await json('/api/admin/hosts', {
method: 'POST',
cookie: adminCookie,
body: { id: 'w-1', endpoint: `http://127.0.0.1:${A1_PORT}`, token: TOKEN, capacityMb: 4096 },
})
assert(r.status === 200, `注册 w-1 应 200(实际 ${r.status})`)
// ── ⑤ 建文件夹(经 RemoteUserFs 落到 worker)─────────────────────────
r = await json('/api/fs/mkdir', { method: 'POST', cookie, body: { path: 'proj' } })
assert(r.status === 200, `mkdir 应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
assert(
(await json('/api/desktop/tree', { cookie })).body.entries.some((e) => e.name === 'proj'),
'工作区里出现 proj',
)
console.log('② 文件面 -> mkdir 落库到 worker(经 agent /fs/mkdir)')
// ── ⑥ 拉起实例(经 agent 起**真 dsh**)───────────────────────────────
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
assert(r.status === 200, `launch 应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
assert(typeof r.body.url === 'string' && r.body.url.startsWith('/u/'), `launch 返回子路径形态 URL(实际 ${r.body.url})`)
console.log('③ 拉起 -> %s(真 dsh 启动中,token 稍后才吐)', r.body.url)
// 直接问 worker(绕过 Manager):实例到底在不在 agent 手上
const onAgent = await fetch(`http://127.0.0.1:${A1_PORT}/instances`, { headers: { 'x-dsh-agent-token': TOKEN } })
console.log(' worker 视角 -> /instances = %s', (await onAgent.text()).slice(0, 200))
// ── ⑦ 轮询到「running **且** launch token 到位」 ─────────────────────
// 真 dsh 与 fake-dsh 不同:进程起来 ≠ 已打印 token。**launch token 回传(P0-6)**
// 只有在 token 真的从 worker 传回 Manager 之后才算成立,所以这里要等到它。
const t0 = Date.now()
let running = false
let tokenSeen = ''
let lastStatus = null
for (let i = 0; i < 90; i += 1) {
const st = await json('/api/dsh/status', { cookie })
lastStatus = st.body
const main = st.body.main
if (main?.status === 'crashed') {
console.error(' 实例崩溃:exitCode=%s lastError=%s', main.exitCode, String(main.lastError).slice(0, 400))
break
}
// 注意:`/api/dsh/status` 的实例视图是**精简视图**(id/port/status/restarts),
// **不含 launchToken** ⇒ token 的存在性用下面的 `/api/dsh/enter` 判定(它回带 token 的 URL)。
if (st.body.running === true) {
running = true
tokenSeen = st.body.url ?? ''
break
}
await sleep(1000)
}
if (!running) {
tailLog('agent-w-1', 20)
tailLog('manager', 10)
}
assert(running, `实例应在 90s 内 running(实际 ${JSON.stringify(lastStatus)?.slice(0, 500)})`)
console.log('④ 状态 -> running=true(真 dsh,隔离=%s,耗时 %ds)', ISO, Math.round((Date.now() - t0) / 1000))
// ── ⑧ **登录直达**(P0-6 的真实端到端):enter 走"复用已运行实例"分支 ⇒ 带 token 的 URL
const enter = await json('/api/dsh/enter', { method: 'POST', cookie })
assert(enter.status === 200, `enter 应 200(实际 ${enter.status} ${JSON.stringify(enter.body)})`)
const launchUrl = enter.body.url
assert(typeof launchUrl === 'string' && launchUrl.includes('token='), `enter 应返回**带 token** 的直达 URL(实际 ${launchUrl})`)
console.log('⑤ 登录直达 -> %s', launchUrl)
let pageStatus = 0
let pageSnippet = ''
for (let i = 0; i < 40; i += 1) {
// 真实浏览器会**跟随重定向**(dsh 首页 303 → 应用页)⇒ 这里也跟随,否则会误判为失败。
// ⚠️ 必须 try/catch:实例刚 spawn 时正在初始化,代理可能中途断连(`other side closed`),
// 这是**启动窗口的正常现象**,重试即可 —— 不捕获会让检查在第一次尝试就失败。
try {
const res = await fetch(base + launchUrl, { headers: { cookie }, redirect: 'follow', signal: AbortSignal.timeout(15_000) })
pageStatus = res.status
if (res.status === 200) {
pageSnippet = (await res.text()).slice(0, 400)
break
}
} catch {
pageStatus = 0
}
await sleep(1000)
}
assert(pageStatus === 200, `实例页面应最终 200(实际 ${pageStatus})`)
console.log('⑥ 实例页面 -> 200(经 Manager 代理到 worker 上 account 沙箱内的真 dsh;已跟随 303 重定向)')
// ── ⑨ 停止 ────────────────────────────────────────────────────────────
r = await json('/api/dsh/stop', { method: 'POST', cookie })
assert(r.status === 200, `stop 应 200(实际 ${r.status})`)
await sleep(500)
assert((await json('/api/dsh/status', { cookie })).body.running === false, 'stop 后 running=false')
console.log('⑦ 停止 -> ok')
// ── ⑩ 第二台 worker + 迁移 ────────────────────────────────────────────
run('agent-w-2', [CLI, 'worker', '--token', TOKEN, '--port', String(A2_PORT), '--host', '127.0.0.1', '--host-id', 'w-2', '--instance-host', '127.0.0.1', '--log-level', 'warn'], agentEnv)
await waitHttp(`http://127.0.0.1:${A2_PORT}/healthz`, 15_000, 'agent w-2')
r = await json('/api/admin/hosts', {
method: 'POST',
cookie: adminCookie,
body: { id: 'w-2', endpoint: `http://127.0.0.1:${A2_PORT}`, token: TOKEN, capacityMb: 4096 },
})
assert(r.status === 200, `注册 w-2 应 200(实际 ${r.status})`)
const hosts = await json('/api/admin/hosts', { cookie: adminCookie })
assert(hosts.body.hosts.some((h) => h.id === 'w-2'), 'w-2 出现在 worker 目录')
assert(!('agentToken' in (hosts.body.hosts[0] ?? {})), '**绝不下发 agentToken**')
console.log('⑧ 第二台 -> w-2 已注册(且列表不含 agentToken)')
// 重新拉起(此刻只有 w-1 是候选 ⇒ 确定性落 w-1),再注册 w-2、再迁移
r = await json('/api/dsh/launch', { method: 'POST', cookie, body: { folder: 'proj' } })
assert(r.status === 200, `再次 launch 应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
for (let i = 0; i < 40 && !(await json('/api/dsh/status', { cookie })).body.running; i += 1) await sleep(1000)
const owner = (await json('/api/dsh/status', { cookie })).body
assert(owner.running === true, '重新拉起后 running=true')
r = await json(`/api/admin/users/${target.id}/dsh/migrate`, {
method: 'POST',
cookie: adminCookie,
body: { targetHost: 'w-2' },
})
assert(r.status === 200, `迁移应 200(实际 ${r.status} ${JSON.stringify(r.body)})`)
assert(r.body.to === 'w-2', `迁移目标应为 w-2(实际 ${r.body.to})`)
for (let i = 0; i < 30 && !(await json('/api/dsh/status', { cookie })).body.running; i += 1) await sleep(1000)
console.log('⑨ 迁移 -> %s → %s(epoch=%d)', r.body.from, r.body.to, r.body.epoch)
// ⚠️ 迁移后实例是**新进程 ⇒ 新 launch token**:旧 URL 里的 token 已失效(404 是**预期**行为)。
// 真实用户会重新走 `/api/dsh/enter`(门户的"进入工作区"就是这个接口)拿**新** URL ⇒ 这里照做。
let newUrl = ''
pageStatus = 0
for (let i = 0; i < 40; i += 1) {
try {
const en = await json('/api/dsh/enter', { method: 'POST', cookie })
if (en.status === 200 && typeof en.body.url === 'string') {
newUrl = en.body.url
const res = await fetch(base + newUrl, { headers: { cookie }, redirect: 'follow', signal: AbortSignal.timeout(15_000) })
pageStatus = res.status
if (res.status === 200) {
pageSnippet = (await res.text()).slice(0, 200)
break
}
}
} catch {
pageStatus = 0
}
await sleep(1000)
}
if (newUrl === '' || newUrl === launchUrl) {
// 诊断:两台 agent 各自认为有什么 + DB 归属如何
for (const [label, port] of [['w-1', A1_PORT], ['w-2', A2_PORT]]) {
const res = await fetch(`http://127.0.0.1:${port}/instances`, { headers: { 'x-dsh-agent-token': TOKEN } })
const body = await res.text()
console.error(` ${label} /instances = ${body.slice(0, 220)}`)
const st = await fetch(`http://127.0.0.1:${port}/status/${target.id}`, { headers: { 'x-dsh-agent-token': TOKEN } })
console.error(` ${label} /status = ${(await st.text()).slice(0, 220)}`)
}
tailLog('agent-w-2', 16)
tailLog('manager', 8)
}
assert(newUrl !== '' && newUrl !== launchUrl, `迁移后 enter 应给**新** URL(旧 ${launchUrl} / 新 ${newUrl})`)
assert(pageStatus === 200, `迁移后经新 URL 的实例页面应 200(实际 ${pageStatus})`)
console.log('⑩ 迁移后 -> enter 返回新 token URL,页面 200(经 w-2)')
// ── ⑪ 观测面 ──────────────────────────────────────────────────────────
const doctor = spawnSync(process.execPath, [CLI, 'doctor'], { cwd: repoRoot, env: { ...process.env, ...managerEnv }, encoding: 'utf8' })
const status = spawnSync(process.execPath, [CLI, 'cluster', 'status'], { cwd: repoRoot, env: { ...process.env, ...managerEnv }, encoding: 'utf8' })
console.log('⑪ dshs doctor -> rc=%d(0 = 无硬失败)', doctor.status ?? -1)
console.log(String(status.stdout).split('\n').slice(0, 8).map((l) => ' ' + l).join('\n'))
// 收尾:停实例(避免留下 dsh 子进程)
await json('/api/dsh/stop', { method: 'POST', cookie })
console.log('\nOK: 真实部署(2 个 worker agent 进程 + 1 个 Manager 进程 + 真 PG)端到端功能确认通过')
console.log(' ✓ 用户流程 ✓ 文件面跨机 ✓ 真 dsh 拉起并可从公网侧取页面 ✓ 停止 ✓ 注册+迁移+迁移后复验 ✓ 观测面')
console.log(' 页面片段:%s', pageSnippet.replace(/\s+/g, ' ').slice(0, 80))
} finally {
shutdownAll()
await sleep(1500)
}