Files
dsh_shenxian/scripts/verify-cluster-live.mjs
T
admin 452924d89c feat(config): 涉密内容外置到配置目录(档案 140)
把散落在代码里的真实部署值统一收进 config/,代码改为引用配置,
使仓库副本/开源导出不再带出生产域名、IP、内网路径与凭据。

新增 config/:platform.env.example(模板)· load.sh(shell 加载器)·
index.cjs(node 加载器)· README.md(键一览与优先级)。
真实值放 config/platform.env —— 已 .gitignore 排除,不入库、不进导出。

TS 侧新增 src/platform-paths.ts 作部署路径的唯一解析处(零副作用):
platformDir/stateDir/backupDir/artifactDir/installDir/scriptPath。
config.ts 接入这些字段;内置中继种子由生产 URL 改为空(改由
DSHS_OVERLAY_BOOTSTRAP_SEEDS 提供)。修掉 5 处硬编码绝对路径,
src/** 注释中性化 116 行/53 文件。

scripts/** 36 个内部运维脚本:真令牌/PG 口令/隧道目标/主机号/路径
一律改从配置取;web/wake.html 的注册域白名单改为运行时从
location.hostname 推导;test/** 夹具 119 行/13 文件改 RFC 2606/5737
保留值,并把「内置种子必须为空」固化为回归断言。

取证:tsc 0 错;npm test 373/375(唯一失败 lease 属既有);
全仓扫描(大小写不敏感)代码面涉密标识 = 0;已部署 47 并零回归
(/opt/dsh/* 未搬家,/var/lib/dshs/platform 未被误建)。
2026-09-19 15:12:19 +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:<pg-port>;以 root 运行(account 隔离要 setpriv/systemd-run)。
* 运行:CLUSTER_LIVE_PG_URL=postgres://dshs:[email protected]:<pg-port>/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 ?? cfg.installDir() + '-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)
}