背景:把平台从「单机单进程」改造成「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
150 lines
6.1 KiB
TypeScript
150 lines
6.1 KiB
TypeScript
/**
|
||
* Backend abstraction for per-user DSH lifecycle (docs/k8s.md §5.2).
|
||
*
|
||
* `LocalSpawner` spawns child processes (setuid/iptables). The route layer
|
||
* depends only on this interface. Shared instance/status types live here so
|
||
* no backend owns them.
|
||
* @module dshs/supervisor/spawner
|
||
*/
|
||
|
||
export type InstanceStatus = 'starting' | 'running' | 'crashed' | 'stopped' | 'failed'
|
||
export type InstanceRole = 'main' | 'watchdog'
|
||
|
||
/** A tracked DSH instance (main or watchdog). `port`/`pid` are local-only. */
|
||
export interface Instance {
|
||
id: string
|
||
userId: string
|
||
role: InstanceRole
|
||
folder: string
|
||
port?: number
|
||
status: InstanceStatus
|
||
pid?: number
|
||
exitCode?: number
|
||
lastError?: string
|
||
/** Rendered cordis patch **content**, not a path — see {@link Spawner.launch}. */
|
||
patch?: string
|
||
/** dsh web 一次性 launch token(本地模式从子进程 stdout 解析),用于拼装可直达的打开 URL。 */
|
||
launchToken?: string
|
||
/** systemd scope 名(bwrap 沙箱隔离时),stop 时用 systemctl stop 正确终止整个 scope。 */
|
||
unit?: string
|
||
/** 崩溃自动重启次数(档案 20 观测面,随实例重建归零)。 */
|
||
restarts?: number
|
||
/** 最近一次崩溃时间(ms epoch)。 */
|
||
lastCrashedAt?: number
|
||
}
|
||
|
||
/** Thrown when a user already has a running main DSH. */
|
||
export class AlreadyRunningError extends Error {
|
||
constructor(userId: string) {
|
||
super(`user ${userId} already has a running DSH`)
|
||
this.name = 'AlreadyRunningError'
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 档案 78:该用户的实例刚因崩溃循环被熔断,冷却期内拒绝启动。
|
||
*
|
||
* 为什么需要它:`circuit-open` 之后若允许立刻重来,崩溃循环可无限重复
|
||
* (用户 F5 / 注入脚本自愈 / 脚本直铺都可能触发),且平台只留一行 stderr。
|
||
* 冷却过后只给一次干净预算。
|
||
*/
|
||
export class CrashBreakerOpenError extends Error {
|
||
readonly userId: string
|
||
/** 冷却结束时刻(ms epoch)。 */
|
||
readonly retryAt: number
|
||
/** 累计熔断次数。 */
|
||
readonly opens: number
|
||
|
||
constructor(userId: string, retryAt: number, opens: number) {
|
||
const waitSec = Math.max(0, Math.ceil((retryAt - Date.now()) / 1000))
|
||
super(`user ${userId} instance crash-breaker open (opens=${opens}, retry in ${waitSec}s)`)
|
||
this.name = 'CrashBreakerOpenError'
|
||
this.userId = userId
|
||
this.retryAt = retryAt
|
||
this.opens = opens
|
||
}
|
||
|
||
/** 距可重试还剩多少毫秒。 */
|
||
get retryAfterMs(): number {
|
||
return Math.max(0, this.retryAt - Date.now())
|
||
}
|
||
}
|
||
|
||
/** A user's main + watchdog pair. */
|
||
export interface UserStatus {
|
||
main?: Instance
|
||
watchdog?: Instance
|
||
}
|
||
|
||
/** The host:port the proxy forwards a user's DSH traffic to. */
|
||
export interface Endpoint {
|
||
host: string
|
||
port: number
|
||
}
|
||
|
||
/**
|
||
* The lifecycle seam the route layer delegates to.
|
||
*
|
||
* `endpointFor` is spawner-specific: `127.0.0.1:<port>` for the local backend
|
||
* (docs/k8s.md §5.4).
|
||
*
|
||
* `launch` takes the rendered cordis patch as **content**, not a path;
|
||
* `LocalSpawner` materializes it to a file inside the user's own volume.
|
||
*/
|
||
export interface Spawner {
|
||
/**
|
||
* 拉起实例。
|
||
*
|
||
* `opts.epoch`(T08 S4):**集群模式下 epoch 是 launch 契约的一部分** —— Manager 先抢占
|
||
* 归属拿到 epoch,再把它随 launch 下发,worker 记下来用于 **self-fencing**
|
||
* (收到更高 epoch 就停掉自己那个实例)。本地模式忽略该字段。
|
||
*
|
||
* `opts.hostId`(T08 S6):**多 worker 时指定落到哪台** —— 由上层选好机、并已用它认领租约,
|
||
* 因此这里必须与租约的 `host_id` 一致(否则归属与实例分离)。单机/1a 忽略。
|
||
*/
|
||
launch(
|
||
userId: string,
|
||
folder: string,
|
||
patch?: string,
|
||
opts?: { force?: boolean; epoch?: number; hostId?: string },
|
||
): Promise<Instance>
|
||
restartMain(userId: string): Promise<Instance | undefined>
|
||
/**
|
||
* 档案 78:熔断观测面(可选 —— 熔断是**本地模式**概念,k8s 模式没有)。
|
||
* 返回 null = 该用户未被熔断冷却;非 null = 正在冷却(含累计熔断次数与解冻时刻)。
|
||
*/
|
||
breakerInfo?(userId: string): { opens: number; openedAt: number; cooldownUntil: number } | null
|
||
|
||
/**
|
||
* 档案 84:实例内存配额观测面(可选 —— 「配额随插件集合推导」是**本地模式**概念)。
|
||
* 返回 `instanceMemMb()` / `heapMbFor()` 的**同源结果**,供实例内的「功能管理」
|
||
* 直接显示真值,而不是各自维护一份必然会漂的估算表。
|
||
*/
|
||
quotaInfo?(userId: string): { baseMb: number; memMb: number; heapMb: number } | null
|
||
|
||
/** Restart every running main so a swapped global API key takes effect (env is a spawn-time snapshot). */
|
||
restartAllMains(): Promise<void>
|
||
spawnWatchdog(userId: string): Promise<Instance | undefined>
|
||
status(userId: string): Promise<UserStatus>
|
||
endpointFor(userId: string): Promise<Endpoint | undefined>
|
||
stop(userId: string, hostId?: string): Promise<void>
|
||
teardown(): Promise<void>
|
||
/** 等待该用户 main 实例打印 launch token(本地模式 = 启动完成的信号)。无实例 /
|
||
* 已崩溃 / 已停 → 立即返回;k8s 模式无 token 概念 → no-op。供 enter 复用分支在返回
|
||
* 打开 URL 前等待,避免把浏览器导向「HTTP 已监听但路由未就绪 → 404」的启动窗口。 */
|
||
waitForLaunchTokenForUser(userId: string, timeoutMs?: number): Promise<void>
|
||
|
||
/**
|
||
* 档案 34:重启该用户实例并**探活**——功能插件启用后判定实例是否还起得来。
|
||
* `ok:false` 时调用方必须回滚/隔离该插件(否则会把实例拖进崩溃循环,档案 25 教训)。
|
||
*/
|
||
restartAndProbe(userId: string, settleMs?: number): Promise<{ ok: boolean; reason: string }>
|
||
/** Record user activity (proxied traffic / entering the workspace) so idle
|
||
* reaping keeps warm instances that are genuinely in use. Local mode tracks
|
||
* this in memory; k8s mode relies on its own session-based reconcile. */
|
||
touch(userId: string): void
|
||
/** Make the user's file sidecar exist and be ready (k8s). No-op under local,
|
||
* where the control plane touches the volume in-process. */
|
||
ensureFileService(userId: string): Promise<void>
|
||
}
|