@mzzsfy/dsh-maintain 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +55 -0
- package/cordis.patch.yml +8 -0
- package/package.json +49 -0
- package/src/client.js +781 -0
- package/src/core.mjs +215 -0
- package/src/index.js +483 -0
- package/src/upgrade.mjs +128 -0
- package/test/client-id.test.mjs +16 -0
- package/test/core.test.mjs +176 -0
- package/test/dist-tags.test.mjs +220 -0
- package/test/logic-extract.mjs +21 -0
- package/test/parity.test.mjs +149 -0
- package/test/restart-ready.test.mjs +198 -0
- package/test/route.test.mjs +425 -0
- package/test/upgrade.test.mjs +65 -0
package/src/index.js
ADDED
|
@@ -0,0 +1,483 @@
|
|
|
1
|
+
// dsh-maintain Host 半区:版本监测 + 一键升级 + 安全重启。
|
|
2
|
+
// 双端模式照 dsh-usage-panel:webServer 具名路由供浏览器半区调用;
|
|
3
|
+
// 设置持久化走 settings 命名空间 maintain,检查结果仅存内存,不落盘。
|
|
4
|
+
|
|
5
|
+
import { readFile } from 'node:fs/promises'
|
|
6
|
+
import { tmpdir } from 'node:os'
|
|
7
|
+
import { join } from 'node:path'
|
|
8
|
+
import { readFileSync, writeFileSync, rmSync } from 'node:fs'
|
|
9
|
+
import z from '@deepseek-ai/schemastery'
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
TARGET_PACKAGE,
|
|
13
|
+
TAG_PLACEHOLDER,
|
|
14
|
+
buildUpgradeCommand,
|
|
15
|
+
fetchDistTags,
|
|
16
|
+
isValidChannelName,
|
|
17
|
+
isValidRegistryBase,
|
|
18
|
+
judgeVersion,
|
|
19
|
+
resolveHostVersion,
|
|
20
|
+
} from './core.mjs'
|
|
21
|
+
import { runUpgrade } from './upgrade.mjs'
|
|
22
|
+
|
|
23
|
+
export const name = 'dsh-maintain'
|
|
24
|
+
|
|
25
|
+
// timer 为软依赖:不进 inject 声明,服务缺失时仅停用自动轮询(状态接口提示),
|
|
26
|
+
// 插件其余能力(状态面板 / 手动检查 / 升级)不受影响,也不因等待服务而阻塞装载
|
|
27
|
+
export const inject = ['webServer']
|
|
28
|
+
|
|
29
|
+
const NAMESPACE = 'maintain'
|
|
30
|
+
|
|
31
|
+
const CHECK_TIMEOUT_MS = 20 * 1000
|
|
32
|
+
// 升级命令超时:client 浮条观察上限与此对拍(parity 锁定),强杀宽限另计
|
|
33
|
+
export const UPGRADE_TIMEOUT_MS = 10 * 60 * 1000
|
|
34
|
+
// 响应发出到执行退出的延迟:保证浏览器收到 200 并进入重启等待态,进程才离场;
|
|
35
|
+
// 导出仅供测试计算延迟窗口等待时长
|
|
36
|
+
export const RESTART_DELAY_MS = 2 * 1000
|
|
37
|
+
// 轮询底层计时粒度;导出仅供 parity 测试与 client 提示文案对拍
|
|
38
|
+
export const TICK_MS = 60 * 1000
|
|
39
|
+
// 轮询间隔上界:超大值会让到期时间戳溢出为 Infinity,轮询静默失效
|
|
40
|
+
export const POLL_INTERVAL_MAX_SEC = 30 * 24 * 60 * 60
|
|
41
|
+
|
|
42
|
+
// 浏览器半区调用的 API 路径清单:client.js 同名常量与之对拍(parity),防单侧改路径生产 404
|
|
43
|
+
export const API_PATHS = Object.freeze({
|
|
44
|
+
STATUS: '/api/maintain/status',
|
|
45
|
+
REFRESH: '/api/maintain/refresh',
|
|
46
|
+
CHANNEL: '/api/maintain/channel',
|
|
47
|
+
UPGRADE_TEMPLATE: '/api/maintain/upgrade-template',
|
|
48
|
+
POLL_INTERVAL: '/api/maintain/poll-interval',
|
|
49
|
+
REGISTRY_BASE: '/api/maintain/registry-base',
|
|
50
|
+
UPGRADE: '/api/maintain/upgrade',
|
|
51
|
+
RESTART: '/api/maintain/restart',
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
// 宿主进程启动时刻:重启探测的第三代际信号(容器内 pid 恒 1 且零失联时 pid 信号失效)
|
|
55
|
+
const BOOT_AT = Date.now() - Math.round(process.uptime() * 1000)
|
|
56
|
+
|
|
57
|
+
// 跨进程升级锁:升级子进程 detached 存活于宿主死后,宿主被外部重启时内存门闩归零
|
|
58
|
+
// 会放行第二次升级,与仍在跑的旧包管理器并发写同一全局目录。锁文件随升级结束删除,
|
|
59
|
+
// 宿主崩溃残留时按 startedAt 过期(超 UPGRADE_TIMEOUT_MS + 强杀宽限)自动失效
|
|
60
|
+
// 升级锁绝对路径:导出仅供测试预热清理(防测试进程中断残留毒化后续运行)
|
|
61
|
+
export const UPGRADE_LOCK_PATH = join(tmpdir(), 'dsh-maintain-upgrade.lock')
|
|
62
|
+
const UPGRADE_LOCK_STALE_MS = UPGRADE_TIMEOUT_MS + 10 * 1000
|
|
63
|
+
|
|
64
|
+
function readUpgradeLock() {
|
|
65
|
+
try {
|
|
66
|
+
const raw = JSON.parse(readFileSync(UPGRADE_LOCK_PATH, 'utf8'))
|
|
67
|
+
if (raw && typeof raw.startedAt === 'number') return raw
|
|
68
|
+
} catch { /* 缺失/损坏等同无锁 */ }
|
|
69
|
+
return null
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function upgradeLockStale(lock) {
|
|
73
|
+
return lock === null || Date.now() - lock.startedAt > UPGRADE_LOCK_STALE_MS
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const DEFAULT_CHANNEL = 'latest'
|
|
77
|
+
// 默认值导出仅供 parity 测试作 host 侧锚点;行为入口全部经 readSettings 回落
|
|
78
|
+
export const DEFAULT_POLL_INTERVAL_SEC = 6 * 60 * 60
|
|
79
|
+
export const DEFAULT_UPGRADE_TEMPLATE = 'npm install -g ' + TARGET_PACKAGE + '@' + TAG_PLACEHOLDER
|
|
80
|
+
export const DEFAULT_REGISTRY_BASE = 'https://registry.npmjs.org'
|
|
81
|
+
|
|
82
|
+
// 注册即声明 GUI 设置表单,schema 默认值即生效默认值(rs-workflow-config 先例)。
|
|
83
|
+
const SETTINGS_SCHEMA = z.object({
|
|
84
|
+
channel: z.string().default(DEFAULT_CHANNEL).description('追踪通道:npm dist-tag 名(latest/next/alpha 等,以检查返回的通道列表为准)'),
|
|
85
|
+
pollIntervalSec: z.number().default(DEFAULT_POLL_INTERVAL_SEC).description('轮询间隔秒数,仅正数启用周期检查'),
|
|
86
|
+
upgradeCommandTemplate: z.string().default(DEFAULT_UPGRADE_TEMPLATE).description('升级命令模板,{tag} 执行时替换为追踪通道,可整体自改为任意命令'),
|
|
87
|
+
registryBase: z.string().default(DEFAULT_REGISTRY_BASE).description('npm registry 基地址,官方源不可达时改为镜像地址'),
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
function sendJson(res, status, payload) {
|
|
91
|
+
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
92
|
+
res.end(JSON.stringify(payload))
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 同源守卫:浏览器写请求恒带 Origin,与 Host 不符即拒;无 Origin 的非浏览器客户端放行。
|
|
96
|
+
// 升级/重启是破坏性端点,与外层鉴权插件互补,阻断跨站简单请求 drive-by 触发。
|
|
97
|
+
// host 比较大小写归一:URL.host 恒小写,请求 Host 头保原始大小写。
|
|
98
|
+
function rejectCrossOrigin(req, res) {
|
|
99
|
+
const origin = req.headers ? req.headers.origin : undefined
|
|
100
|
+
if (!origin) return false
|
|
101
|
+
const host = String((req.headers && req.headers.host) || '')
|
|
102
|
+
let sameOrigin = false
|
|
103
|
+
try {
|
|
104
|
+
// host 为空即不同源:缺失 Host 头与 file:// Origin 的空 host 不得双空判同源
|
|
105
|
+
sameOrigin = host.length > 0 && new URL(origin).host === host.toLowerCase()
|
|
106
|
+
} catch {
|
|
107
|
+
sameOrigin = false
|
|
108
|
+
}
|
|
109
|
+
if (sameOrigin) return false
|
|
110
|
+
sendJson(res, 403, { error: '跨源请求被拒绝' })
|
|
111
|
+
return true
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// 路由样板收敛:方法守卫与跨源守卫统一在此,业务异常统一归一 400,handler 只留业务体
|
|
115
|
+
const route = (method, guards, handler) => async (req, res) => {
|
|
116
|
+
if (req.method !== method) {
|
|
117
|
+
sendJson(res, 405, { error: 'method not allowed' })
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
if (guards.crossOrigin && rejectCrossOrigin(req, res)) return
|
|
121
|
+
try {
|
|
122
|
+
await handler(req, res)
|
|
123
|
+
} catch (error) {
|
|
124
|
+
sendJson(res, 400, { error: error && error.message ? error.message : String(error) })
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function readBody(req) {
|
|
129
|
+
const BODY_MAX_BYTES = 64 * 1024
|
|
130
|
+
return new Promise((resolve, reject) => {
|
|
131
|
+
let size = 0
|
|
132
|
+
const chunks = []
|
|
133
|
+
req.on('data', (chunk) => {
|
|
134
|
+
size += chunk.length
|
|
135
|
+
if (size > BODY_MAX_BYTES) {
|
|
136
|
+
// 超限即断连:后续 destroy 触发的 error 由已 settle 的 Promise 吸收,属预期;
|
|
137
|
+
// 客户端收到连接重置即视为超限,不再尝试写结构化错误
|
|
138
|
+
reject(new Error('请求体超过上限'))
|
|
139
|
+
req.destroy()
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
chunks.push(chunk)
|
|
143
|
+
})
|
|
144
|
+
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
145
|
+
req.on('error', reject)
|
|
146
|
+
})
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function readSettings(ctx) {
|
|
150
|
+
const settings = ctx.get('settings')
|
|
151
|
+
// 方法面守卫:settings 服务在但缺 get(宿主升级变更面)时回落默认值
|
|
152
|
+
const value = settings && typeof settings.get === 'function' ? settings.get(NAMESPACE) : undefined
|
|
153
|
+
return {
|
|
154
|
+
channel: value && typeof value.channel === 'string' && value.channel.trim().length > 0 ? value.channel.trim() : DEFAULT_CHANNEL,
|
|
155
|
+
pollIntervalSec:
|
|
156
|
+
value && typeof value.pollIntervalSec === 'number' && Number.isFinite(value.pollIntervalSec)
|
|
157
|
+
? value.pollIntervalSec
|
|
158
|
+
: DEFAULT_POLL_INTERVAL_SEC,
|
|
159
|
+
upgradeCommandTemplate:
|
|
160
|
+
value && typeof value.upgradeCommandTemplate === 'string' && value.upgradeCommandTemplate.trim().length > 0
|
|
161
|
+
? value.upgradeCommandTemplate
|
|
162
|
+
: DEFAULT_UPGRADE_TEMPLATE,
|
|
163
|
+
registryBase:
|
|
164
|
+
value && typeof value.registryBase === 'string' && value.registryBase.trim().length > 0
|
|
165
|
+
? value.registryBase.trim()
|
|
166
|
+
: DEFAULT_REGISTRY_BASE,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function resolveCurrentHostVersion() {
|
|
171
|
+
return resolveHostVersion({
|
|
172
|
+
execPath: process.execPath,
|
|
173
|
+
platform: process.platform,
|
|
174
|
+
readFileImpl: readFile,
|
|
175
|
+
resolveImpl: undefined,
|
|
176
|
+
})
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** @param {import('@deepseek-ai/cordis').Context} ctx */
|
|
180
|
+
export function apply(ctx) {
|
|
181
|
+
// 启动器在挂载前提供 appExit(有界退出,5 秒兜底强制);缺失时重启能力关闭
|
|
182
|
+
const exit = ctx.get('appExit')
|
|
183
|
+
if (typeof exit !== 'function') {
|
|
184
|
+
console.warn('[dsh-maintain] 启动器未提供 appExit,就地重启能力关闭(面板按钮禁用)')
|
|
185
|
+
}
|
|
186
|
+
// 幽灵锁检测:宿主外部重启后残留的锁文件若仍在有效期,升级能力保持锁定
|
|
187
|
+
const staleLock = upgradeLockStale(readUpgradeLock()) ? null : readUpgradeLock()
|
|
188
|
+
if (staleLock !== null) {
|
|
189
|
+
console.warn('[dsh-maintain] 检测到未过期的升级锁(可能存在残留升级子进程),升级端点保持拒绝直至锁过期: ' + UPGRADE_LOCK_PATH)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// 内存快照:仅存当前态,进程重启后从启动检查重新开始(设计约束:不持久化)
|
|
193
|
+
const snapshot = { currentVersion: null, tags: null, checkedAt: null, error: null }
|
|
194
|
+
let upgrade = { running: false, last: null }
|
|
195
|
+
let checkInFlight = null
|
|
196
|
+
let nextDueAt = null
|
|
197
|
+
let restartScheduled = false
|
|
198
|
+
|
|
199
|
+
function runCheck() {
|
|
200
|
+
if (checkInFlight) return checkInFlight
|
|
201
|
+
checkInFlight = (async () => {
|
|
202
|
+
const config = readSettings(ctx)
|
|
203
|
+
try {
|
|
204
|
+
snapshot.tags = await fetchDistTags({ registryBase: config.registryBase, timeoutMs: CHECK_TIMEOUT_MS })
|
|
205
|
+
snapshot.error = null
|
|
206
|
+
} catch (error) {
|
|
207
|
+
// registry 不可达:保留上次 tags,仅记录错误
|
|
208
|
+
snapshot.error = error && error.message ? error.message : String(error)
|
|
209
|
+
}
|
|
210
|
+
snapshot.currentVersion = await resolveCurrentHostVersion()
|
|
211
|
+
snapshot.checkedAt = Date.now()
|
|
212
|
+
return snapshot
|
|
213
|
+
})()
|
|
214
|
+
return checkInFlight.finally(() => {
|
|
215
|
+
checkInFlight = null
|
|
216
|
+
})
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// 排空旧配置的在途检查再重查:runCheck 以 checkInFlight 去重,不排空会把
|
|
220
|
+
// 旧 registryBase 结果当作新配置的检查返回
|
|
221
|
+
async function drainInFlightThenCheck() {
|
|
222
|
+
if (checkInFlight) await checkInFlight
|
|
223
|
+
await runCheck()
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function scheduleNext() {
|
|
227
|
+
const intervalSec = readSettings(ctx).pollIntervalSec
|
|
228
|
+
nextDueAt = intervalSec > 0 && intervalSec <= POLL_INTERVAL_MAX_SEC ? Date.now() + intervalSec * 1000 : null
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 固定短 tick + 到期判断:间隔设置变更即时生效。timer 软依赖经嵌套 inject 等待:
|
|
232
|
+
// 服务激活才武装轮询,缺失则回调不执行,面板以 pollRunning 提示降级。
|
|
233
|
+
// dispose 显式挂回插件 fiber:timer 服务重启导致嵌套 fiber 重跑时不产生双 interval
|
|
234
|
+
let pollRunning = false
|
|
235
|
+
ctx.inject(['timer'], (timerCtx) => {
|
|
236
|
+
if (typeof timerCtx.interval !== 'function') {
|
|
237
|
+
console.warn('[dsh-maintain] timer 服务方法面不可用,自动轮询停用(面板可手动检查更新)')
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
const dispose = timerCtx.interval(() => {
|
|
241
|
+
if (checkInFlight !== null || nextDueAt === null || Date.now() < nextDueAt) return
|
|
242
|
+
runCheck().then(scheduleNext, scheduleNext)
|
|
243
|
+
}, TICK_MS)
|
|
244
|
+
ctx.effect(() => dispose, 'dsh-maintain poll interval')
|
|
245
|
+
pollRunning = true
|
|
246
|
+
})
|
|
247
|
+
|
|
248
|
+
function currentStatus() {
|
|
249
|
+
const config = readSettings(ctx)
|
|
250
|
+
const judged = judgeVersion({ currentVersion: snapshot.currentVersion, tags: snapshot.tags, channel: config.channel })
|
|
251
|
+
// running 即视为持锁:省一次盘读,且窗口期语义与 upgrade 路由的门闩一致
|
|
252
|
+
const lock = upgrade.running === true ? { startedAt: Date.now() } : readUpgradeLock()
|
|
253
|
+
return {
|
|
254
|
+
packageName: TARGET_PACKAGE,
|
|
255
|
+
pid: process.pid,
|
|
256
|
+
bootAt: BOOT_AT,
|
|
257
|
+
pollRunning,
|
|
258
|
+
upgradeLockHeld: lock !== null && !upgradeLockStale(lock),
|
|
259
|
+
currentVersion: snapshot.currentVersion,
|
|
260
|
+
channel: config.channel,
|
|
261
|
+
upgradeTemplate: config.upgradeCommandTemplate,
|
|
262
|
+
pollIntervalSec: config.pollIntervalSec,
|
|
263
|
+
registryBase: config.registryBase,
|
|
264
|
+
tags: snapshot.tags,
|
|
265
|
+
channelLatest: judged.channelLatest,
|
|
266
|
+
verdict: judged.verdict,
|
|
267
|
+
reason: judged.reason,
|
|
268
|
+
checkedAt: snapshot.checkedAt,
|
|
269
|
+
checkError: snapshot.error,
|
|
270
|
+
upgrade,
|
|
271
|
+
canRestart: typeof exit === 'function',
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
function triggerUpgrade() {
|
|
276
|
+
const config = readSettings(ctx)
|
|
277
|
+
// 模板校验同步失败即同步 throw,由调用方 try/catch 转 400,不走异步通道
|
|
278
|
+
const command = buildUpgradeCommand({ template: config.upgradeCommandTemplate, tag: config.channel })
|
|
279
|
+
const last = { command, startedAt: Date.now(), ok: false, finishedAt: null, timedOut: false, stillRunning: false, code: null, stdoutTail: '', stderrTail: '', error: null }
|
|
280
|
+
// running 即串行化门闩:路由检查与本处置位之间无 await,单线程下无竞态窗口
|
|
281
|
+
upgrade = { running: true, last }
|
|
282
|
+
try {
|
|
283
|
+
writeFileSync(UPGRADE_LOCK_PATH, JSON.stringify({ startedAt: last.startedAt, pid: process.pid }), 'utf8')
|
|
284
|
+
} catch { /* 锁不可写仅损失跨进程防护,内存门闩仍生效 */ }
|
|
285
|
+
console.warn('[dsh-maintain] 升级开始: ' + command)
|
|
286
|
+
runUpgrade({ command, timeoutMs: UPGRADE_TIMEOUT_MS })
|
|
287
|
+
.then((result) => {
|
|
288
|
+
upgrade = {
|
|
289
|
+
running: false,
|
|
290
|
+
last: { ...last, ok: result.ok, finishedAt: Date.now(), timedOut: result.timedOut, stillRunning: result.stillRunning === true, code: result.code, stdoutTail: result.stdoutTail, stderrTail: result.stderrTail },
|
|
291
|
+
}
|
|
292
|
+
})
|
|
293
|
+
.catch((error) => {
|
|
294
|
+
upgrade = { running: false, last: { ...last, finishedAt: Date.now(), error: error && error.message ? error.message : String(error) } }
|
|
295
|
+
})
|
|
296
|
+
.then(() => {
|
|
297
|
+
// stillRunning(强杀后进程树疑似仍在写全局目录)时保留锁文件,由过期机制收敛,
|
|
298
|
+
// 与"宿主死后幽灵锁"防线对称;正常落定即删
|
|
299
|
+
if (upgrade.last.stillRunning === true) {
|
|
300
|
+
console.warn('[dsh-maintain] 升级超时强杀且进程疑似仍存活,升级锁保留至过期: ' + UPGRADE_LOCK_PATH)
|
|
301
|
+
} else {
|
|
302
|
+
try {
|
|
303
|
+
rmSync(UPGRADE_LOCK_PATH, { force: true })
|
|
304
|
+
} catch (lockError) {
|
|
305
|
+
console.warn('[dsh-maintain] 升级锁删除失败(将由过期机制收敛): ' + (lockError?.message ?? lockError))
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
console.warn('[dsh-maintain] 升级结束: ok=' + upgrade.last.ok + (upgrade.last.code !== null ? ' 退出码=' + upgrade.last.code : '') + (upgrade.last.error ? ' ' + upgrade.last.error : ''))
|
|
309
|
+
})
|
|
310
|
+
// 升级结束后自动重新检查版本并重排轮询(命令可能改了本地版本)
|
|
311
|
+
.then(runCheck, runCheck)
|
|
312
|
+
.then(scheduleNext, scheduleNext)
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const WRITE = { crossOrigin: true }
|
|
316
|
+
|
|
317
|
+
const routes = [
|
|
318
|
+
{
|
|
319
|
+
path: API_PATHS.STATUS,
|
|
320
|
+
handler: route('GET', {}, async (req, res) => {
|
|
321
|
+
sendJson(res, 200, currentStatus())
|
|
322
|
+
}),
|
|
323
|
+
},
|
|
324
|
+
{
|
|
325
|
+
path: API_PATHS.REFRESH,
|
|
326
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
327
|
+
await drainInFlightThenCheck()
|
|
328
|
+
scheduleNext()
|
|
329
|
+
sendJson(res, 200, currentStatus())
|
|
330
|
+
}),
|
|
331
|
+
},
|
|
332
|
+
{
|
|
333
|
+
path: API_PATHS.CHANNEL,
|
|
334
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
335
|
+
const body = JSON.parse(await readBody(req))
|
|
336
|
+
const channel = body && typeof body.channel === 'string' ? body.channel.trim() : ''
|
|
337
|
+
if (channel.length === 0) {
|
|
338
|
+
sendJson(res, 400, { error: 'channel 不能为空' })
|
|
339
|
+
return
|
|
340
|
+
}
|
|
341
|
+
if (!isValidChannelName(channel)) {
|
|
342
|
+
sendJson(res, 400, { error: '通道名含非法字符,仅允许字母/数字/-/./_ : ' + channel })
|
|
343
|
+
return
|
|
344
|
+
}
|
|
345
|
+
// tags 未就绪时白名单兜底校验,远端可控的 tag 名不落盘
|
|
346
|
+
if (snapshot.tags !== null && !Object.prototype.hasOwnProperty.call(snapshot.tags, channel)) {
|
|
347
|
+
sendJson(res, 400, { error: '通道 ' + channel + ' 不在当前 dist-tags 中' })
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
const settings = ctx.get('settings')
|
|
351
|
+
if (!settings) {
|
|
352
|
+
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
353
|
+
return
|
|
354
|
+
}
|
|
355
|
+
await settings.update(NAMESPACE, { channel })
|
|
356
|
+
// 切通道无需重查:dist-tags 与 channel 无关,白名单已用现有 snapshot 校验
|
|
357
|
+
scheduleNext()
|
|
358
|
+
sendJson(res, 200, currentStatus())
|
|
359
|
+
}),
|
|
360
|
+
},
|
|
361
|
+
{
|
|
362
|
+
path: API_PATHS.UPGRADE_TEMPLATE,
|
|
363
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
364
|
+
const body = JSON.parse(await readBody(req))
|
|
365
|
+
const template = body && typeof body.template === 'string' ? body.template.trim() : ''
|
|
366
|
+
if (template.length === 0) {
|
|
367
|
+
sendJson(res, 400, { error: '升级命令不能为空' })
|
|
368
|
+
return
|
|
369
|
+
}
|
|
370
|
+
const settings = ctx.get('settings')
|
|
371
|
+
if (!settings) {
|
|
372
|
+
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
373
|
+
return
|
|
374
|
+
}
|
|
375
|
+
await settings.update(NAMESPACE, { upgradeCommandTemplate: template })
|
|
376
|
+
sendJson(res, 200, currentStatus())
|
|
377
|
+
}),
|
|
378
|
+
},
|
|
379
|
+
{
|
|
380
|
+
path: API_PATHS.POLL_INTERVAL,
|
|
381
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
382
|
+
const body = JSON.parse(await readBody(req))
|
|
383
|
+
// 严格类型:字符串/ null 等经 Number() 宽转后可能变 0,静默翻转轮询开关
|
|
384
|
+
const seconds = body && typeof body.seconds === 'number' ? body.seconds : NaN
|
|
385
|
+
if (!Number.isFinite(seconds) || seconds < 0) {
|
|
386
|
+
sendJson(res, 400, { error: '轮询间隔必须是不小于 0 的秒数' })
|
|
387
|
+
return
|
|
388
|
+
}
|
|
389
|
+
// 上界防溢出:超过 30 天的间隔无运维意义,且秒转毫秒会溢出令轮询静默失效
|
|
390
|
+
if (seconds > POLL_INTERVAL_MAX_SEC) {
|
|
391
|
+
sendJson(res, 400, { error: '轮询间隔不能超过 ' + POLL_INTERVAL_MAX_SEC + ' 秒' })
|
|
392
|
+
return
|
|
393
|
+
}
|
|
394
|
+
const settings = ctx.get('settings')
|
|
395
|
+
if (!settings) {
|
|
396
|
+
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
397
|
+
return
|
|
398
|
+
}
|
|
399
|
+
await settings.update(NAMESPACE, { pollIntervalSec: seconds })
|
|
400
|
+
scheduleNext()
|
|
401
|
+
sendJson(res, 200, currentStatus())
|
|
402
|
+
}),
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
path: API_PATHS.REGISTRY_BASE,
|
|
406
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
407
|
+
const body = JSON.parse(await readBody(req))
|
|
408
|
+
const base = body && typeof body.base === 'string' ? body.base.trim() : ''
|
|
409
|
+
if (!isValidRegistryBase(base)) {
|
|
410
|
+
sendJson(res, 400, { error: 'registry 基地址须为 http(s) 地址且不带查询串或锚点' })
|
|
411
|
+
return
|
|
412
|
+
}
|
|
413
|
+
const settings = ctx.get('settings')
|
|
414
|
+
if (!settings) {
|
|
415
|
+
sendJson(res, 500, { error: 'settings 服务不可用' })
|
|
416
|
+
return
|
|
417
|
+
}
|
|
418
|
+
await settings.update(NAMESPACE, { registryBase: base })
|
|
419
|
+
await drainInFlightThenCheck()
|
|
420
|
+
scheduleNext()
|
|
421
|
+
sendJson(res, 200, currentStatus())
|
|
422
|
+
}),
|
|
423
|
+
},
|
|
424
|
+
{
|
|
425
|
+
path: API_PATHS.UPGRADE,
|
|
426
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
427
|
+
// 与重启调度双向互斥:restartScheduled 置位到 exit(0) 执行的窗口内触发升级,
|
|
428
|
+
// detached 孤儿会与新宿主交错
|
|
429
|
+
if (upgrade.running || restartScheduled) {
|
|
430
|
+
sendJson(res, 409, { error: restartScheduled ? '重启已调度,禁止触发升级' : '升级进行中' })
|
|
431
|
+
return
|
|
432
|
+
}
|
|
433
|
+
if (upgradeLockStale(readUpgradeLock()) === false) {
|
|
434
|
+
sendJson(res, 409, { error: '存在未过期的升级锁(可能有残留升级子进程),等待锁过期或删除 ' + UPGRADE_LOCK_PATH })
|
|
435
|
+
return
|
|
436
|
+
}
|
|
437
|
+
triggerUpgrade()
|
|
438
|
+
sendJson(res, 200, currentStatus())
|
|
439
|
+
}),
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
path: API_PATHS.RESTART,
|
|
443
|
+
handler: route('POST', WRITE, async (req, res) => {
|
|
444
|
+
// 与升级门闩互斥:升级子进程经 detached+unref 存活于宿主死后,
|
|
445
|
+
// 重启后新宿主门闩归零会放行第二次升级,双 npm install 并发写全局目录
|
|
446
|
+
if (upgrade.running) {
|
|
447
|
+
sendJson(res, 409, { error: '升级进行中,禁止重启;等待升级完成后重试' })
|
|
448
|
+
return
|
|
449
|
+
}
|
|
450
|
+
if (typeof exit !== 'function') {
|
|
451
|
+
sendJson(res, 500, { error: '启动器未提供 appExit,无法重启' })
|
|
452
|
+
return
|
|
453
|
+
}
|
|
454
|
+
if (restartScheduled) {
|
|
455
|
+
sendJson(res, 200, { ok: true, restarting: true })
|
|
456
|
+
return
|
|
457
|
+
}
|
|
458
|
+
restartScheduled = true
|
|
459
|
+
sendJson(res, 200, { ok: true, restarting: true })
|
|
460
|
+
// 退出延迟与响应冲刷解耦:客户端在冲刷完成前断连会让 end 回调失效,绑定其上会使重启悬空;
|
|
461
|
+
// 同步发出响应后延迟退出,延迟本身保证回执先于进程离场送达
|
|
462
|
+
setTimeout(() => exit(0), RESTART_DELAY_MS)
|
|
463
|
+
}),
|
|
464
|
+
},
|
|
465
|
+
]
|
|
466
|
+
|
|
467
|
+
for (const route of routes) {
|
|
468
|
+
ctx.effect(() => ctx.webServer.register({ kind: 'exact', path: route.path, handler: route.handler }), 'dsh-maintain ' + route.path)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
472
|
+
// 方法面防御对齐 timer 软依赖:宿主升级变更 settings 服务方法面时干净降级并留痕,
|
|
473
|
+
// 不让 fiber 激活即抛错拖垮启动检查与全部写路由
|
|
474
|
+
if (!settingsCtx.settings || typeof settingsCtx.settings.register !== 'function' || typeof settingsCtx.settings.update !== 'function') {
|
|
475
|
+
console.warn('[dsh-maintain] settings 服务方法面不可用,版本检查停用,通道/设置保存不可用')
|
|
476
|
+
return
|
|
477
|
+
}
|
|
478
|
+
settingsCtx.settings.register(NAMESPACE, SETTINGS_SCHEMA)
|
|
479
|
+
// 启动检查放在 settings 注册之后:命名空间未注册时 readSettings 只能拿默认值,
|
|
480
|
+
// 配置了镜像地址的部署会确定性检查失败
|
|
481
|
+
runCheck().then(scheduleNext, scheduleNext)
|
|
482
|
+
})
|
|
483
|
+
}
|
package/src/upgrade.mjs
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// 升级命令执行器:受控 spawn + 超时树终止 + 尾部输出收集。
|
|
2
|
+
// 自建而非用宿主 ctx.subprocess:需以假命令真实进程做单测,且行为完全本地可控。
|
|
3
|
+
// 命令模板是含参数的完整 shell 命令串,两端都必须 shell 化执行:Windows 经 cmd.exe
|
|
4
|
+
// (.cmd shim,CVE-2024-27980 加固后 spawn 无 shell 会拒绝 .cmd),POSIX 经 /bin/sh -c
|
|
5
|
+
// (shell:false 会把整串当可执行文件路径,必 ENOENT)。访问控制完全由外层鉴权
|
|
6
|
+
// 插件(dsh-web-startup-auth)负责,shell 化不扩大攻击面。
|
|
7
|
+
|
|
8
|
+
import { spawn } from 'node:child_process'
|
|
9
|
+
|
|
10
|
+
const OUTPUT_TAIL_BYTES = 2000
|
|
11
|
+
const SPAWN_ERROR_CODE = 127
|
|
12
|
+
// 树终止后的宽限期:子进程树拒死(权限/管道悬挂)时强制收敛,防升级门闩永久卡死
|
|
13
|
+
const KILL_GRACE_MS = 5 * 1000
|
|
14
|
+
|
|
15
|
+
function collectTail(stream) {
|
|
16
|
+
let text = ''
|
|
17
|
+
stream.setEncoding('utf8')
|
|
18
|
+
stream.on('data', (chunk) => {
|
|
19
|
+
text = (text + chunk).slice(-OUTPUT_TAIL_BYTES)
|
|
20
|
+
})
|
|
21
|
+
return () => text.trim()
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// POSIX 侧 detached 建进程组,超时按组杀(对齐 win32 taskkill /T 语义);
|
|
25
|
+
// npm install 派生的孙进程不再幸存占住 stdio 管道。
|
|
26
|
+
function killTree(child, platform) {
|
|
27
|
+
if (platform === 'win32') {
|
|
28
|
+
if (child.pid) {
|
|
29
|
+
spawn('taskkill', ['/pid', String(child.pid), '/T', '/F'], { stdio: 'ignore' })
|
|
30
|
+
.on('error', () => {})
|
|
31
|
+
}
|
|
32
|
+
return
|
|
33
|
+
}
|
|
34
|
+
if (child.pid) {
|
|
35
|
+
try {
|
|
36
|
+
process.kill(-child.pid, 'SIGKILL')
|
|
37
|
+
return
|
|
38
|
+
} catch {
|
|
39
|
+
// 进程组已不存在,回退直杀
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
child.kill('SIGKILL')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// 宽限期兜底收敛时探测主进程是否仍存活:stillRunning=true 意味着升级门闩已释放
|
|
46
|
+
// 但旧进程树可能仍在写全局目录,调用方应保持升级入口禁用直至确认退出
|
|
47
|
+
function probeStillRunning(child, platform) {
|
|
48
|
+
if (!child.pid) return false
|
|
49
|
+
if (platform === 'win32') {
|
|
50
|
+
// win32 侧 taskkill 失败事件无同步回执,保守报告存活
|
|
51
|
+
return true
|
|
52
|
+
}
|
|
53
|
+
try {
|
|
54
|
+
process.kill(child.pid, 0)
|
|
55
|
+
return true
|
|
56
|
+
} catch {
|
|
57
|
+
return false
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 执行结束返回结果对象,一切失败(含 spawn 本身失败与树终止后进程拒死)都收敛为
|
|
62
|
+
// ok:false,不抛错、不悬挂:kill 后等 close,宽限期到点仍未 close 即强制 resolve。
|
|
63
|
+
export function runUpgrade({ command, timeoutMs }) {
|
|
64
|
+
const platform = process.platform
|
|
65
|
+
return new Promise((resolve) => {
|
|
66
|
+
const startedAt = Date.now()
|
|
67
|
+
let child
|
|
68
|
+
try {
|
|
69
|
+
child = spawn(command, {
|
|
70
|
+
shell: true,
|
|
71
|
+
detached: platform !== 'win32',
|
|
72
|
+
windowsHide: true,
|
|
73
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
74
|
+
})
|
|
75
|
+
} catch (error) {
|
|
76
|
+
resolve({
|
|
77
|
+
ok: false,
|
|
78
|
+
code: SPAWN_ERROR_CODE,
|
|
79
|
+
timedOut: false,
|
|
80
|
+
stdoutTail: '',
|
|
81
|
+
stderrTail: error && error.message ? error.message : String(error),
|
|
82
|
+
durationMs: Date.now() - startedAt,
|
|
83
|
+
})
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let settled = false
|
|
88
|
+
let killRequested = false
|
|
89
|
+
const getStdoutTail = collectTail(child.stdout)
|
|
90
|
+
const getStderrTail = collectTail(child.stderr)
|
|
91
|
+
const settle = (code) => {
|
|
92
|
+
if (settled) return
|
|
93
|
+
settled = true
|
|
94
|
+
clearTimeout(timer)
|
|
95
|
+
clearTimeout(graceTimer)
|
|
96
|
+
// 兜底收敛时拒死进程仍持有 stdio 管道:显式销毁并不再让子进程持有事件循环,
|
|
97
|
+
// 防宿主退出悬挂与门闩释放后的管道泄漏
|
|
98
|
+
child.stdout.destroy()
|
|
99
|
+
child.stderr.destroy()
|
|
100
|
+
child.unref()
|
|
101
|
+
resolve({
|
|
102
|
+
ok: code === 0,
|
|
103
|
+
code,
|
|
104
|
+
timedOut: killRequested,
|
|
105
|
+
// 宽限期兜底收敛(code=null 且非正常 close)时主进程可能仍存活
|
|
106
|
+
stillRunning: code === null && killRequested ? probeStillRunning(child, platform) : false,
|
|
107
|
+
stdoutTail: getStdoutTail(),
|
|
108
|
+
stderrTail: getStderrTail(),
|
|
109
|
+
durationMs: Date.now() - startedAt,
|
|
110
|
+
})
|
|
111
|
+
}
|
|
112
|
+
const timer = setTimeout(() => {
|
|
113
|
+
killRequested = true
|
|
114
|
+
killTree(child, platform)
|
|
115
|
+
// 树终止后正常路径等 close;拒死场景由宽限期兜底强制收敛
|
|
116
|
+
graceTimer = setTimeout(() => settle(null), KILL_GRACE_MS)
|
|
117
|
+
}, timeoutMs)
|
|
118
|
+
let graceTimer = null
|
|
119
|
+
|
|
120
|
+
child.on('error', () => {
|
|
121
|
+
// error 事件后必有 close;此处不 resolve,交给 close 统一收敛
|
|
122
|
+
})
|
|
123
|
+
|
|
124
|
+
child.on('close', (code) => {
|
|
125
|
+
settle(code)
|
|
126
|
+
})
|
|
127
|
+
})
|
|
128
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// client.js 注册 id 守卫:loader 按 graph row id(完整包名)匹配注册,短名即加载失败。
|
|
2
|
+
import { readFileSync } from 'node:fs'
|
|
3
|
+
import { dirname, join } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import test from 'node:test'
|
|
6
|
+
import assert from 'node:assert/strict'
|
|
7
|
+
|
|
8
|
+
const PKG_ROOT = join(dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
const { name } = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'))
|
|
10
|
+
|
|
11
|
+
test('client.js 注册 id 为完整包名', () => {
|
|
12
|
+
const source = readFileSync(join(PKG_ROOT, 'src', 'client.js'), 'utf8')
|
|
13
|
+
const match = source.match(/__ModuleLoader__\.load\(\{\s*id:\s*'([^']+)'/)
|
|
14
|
+
assert.ok(match, 'client.js 缺少 __ModuleLoader__.load 注册')
|
|
15
|
+
assert.equal(match[1], name)
|
|
16
|
+
})
|