@hunan36/dsh-ssh-helper 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +330 -2
- package/README.zh.md +331 -0
- package/client.js +2786 -0
- package/cordis.patch.yml +13 -0
- package/docs/screenshot-accounts.png +0 -0
- package/docs/screenshot-activity.png +0 -0
- package/docs/screenshot-panel.png +0 -0
- package/docs/screenshot-resource-detail.png +0 -0
- package/host/accounts.js +688 -0
- package/host/api.js +440 -0
- package/host/atomic.js +160 -0
- package/host/discover.js +440 -0
- package/host/gate.js +264 -0
- package/host/history.js +276 -0
- package/host/prompt.js +103 -0
- package/host/resources.js +604 -0
- package/host/shell.js +194 -0
- package/host/ssh.js +460 -0
- package/host/tools.js +1086 -0
- package/host/update.js +992 -0
- package/host/upload.js +399 -0
- package/icon.svg +19 -0
- package/index.js +141 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +75 -4
package/host/update.js
ADDED
|
@@ -0,0 +1,992 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Approval-gated remote update and rollback service.
|
|
3
|
+
*
|
|
4
|
+
* `plan` performs only read-only preflight work, caches a short-lived plan bound
|
|
5
|
+
* to one agent, and returns it. `apply` re-validates the plan, backs up the
|
|
6
|
+
* current directory, runs update/restart/verify commands, prunes old backups and
|
|
7
|
+
* records history; any failure stops the sequence, keeps the backup and never
|
|
8
|
+
* auto-rolls back. `rollback` restores one archived backup.
|
|
9
|
+
*
|
|
10
|
+
* The service itself does not know about the approval pipeline: `host/gate.js`
|
|
11
|
+
* is the only place that turns a valid plan/history entry into an approval ask.
|
|
12
|
+
*
|
|
13
|
+
* @module host/update
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { randomUUID } from 'node:crypto'
|
|
17
|
+
import path from 'node:path'
|
|
18
|
+
import { buildInspectCommand, parseInspectOutput } from './discover.js'
|
|
19
|
+
import { backupLayout, remotePathWarnings, safeSegment, shQuote, validateRemotePath } from './shell.js'
|
|
20
|
+
|
|
21
|
+
/** Cached plan lifetime (15 minutes). */
|
|
22
|
+
const PLAN_TTL_MS = 15 * 60 * 1000
|
|
23
|
+
/** Maximum cached plans. */
|
|
24
|
+
const PLAN_MAX = 50
|
|
25
|
+
/** Maximum pinned rollback targets. */
|
|
26
|
+
const ROLLBACK_MAX = 50
|
|
27
|
+
/** Default preflight timeout (60 s). */
|
|
28
|
+
const DEFAULT_PREFLIGHT_TIMEOUT_MS = 60_000
|
|
29
|
+
/** Minimum accepted timeout (1 s). */
|
|
30
|
+
const MIN_TIMEOUT_MS = 1_000
|
|
31
|
+
/** Maximum accepted timeout (10 min). */
|
|
32
|
+
const MAX_TIMEOUT_MS = 600_000
|
|
33
|
+
/** Captured output tail length. */
|
|
34
|
+
const TAIL_MAX_LENGTH = 8192
|
|
35
|
+
/** Command text length kept in history / approval reasons. */
|
|
36
|
+
const COMMAND_MAX_LENGTH = 8192
|
|
37
|
+
/** Extra disk headroom required for a backup (10 MiB). */
|
|
38
|
+
const BACKUP_HEADROOM_KB = 10 * 1024
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Build one tagged error.
|
|
42
|
+
* @param {string} code - machine-readable code.
|
|
43
|
+
* @param {string} message - Chinese message.
|
|
44
|
+
* @param {unknown} [cause] - underlying failure.
|
|
45
|
+
* @returns {Error & { code: string }} tagged error.
|
|
46
|
+
*/
|
|
47
|
+
function taggedError(code, message, cause) {
|
|
48
|
+
const error = new Error(message)
|
|
49
|
+
error.code = code
|
|
50
|
+
if (cause !== undefined) error.cause = cause
|
|
51
|
+
return error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** @returns {Error} invalid-input error. */
|
|
55
|
+
function invalid(message) {
|
|
56
|
+
return taggedError('invalid-input', message)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Clamp one integer option.
|
|
61
|
+
* @param {unknown} value - raw value.
|
|
62
|
+
* @param {number} fallback - default.
|
|
63
|
+
* @param {number} min - lower bound.
|
|
64
|
+
* @param {number} max - upper bound.
|
|
65
|
+
* @returns {number} clamped integer.
|
|
66
|
+
*/
|
|
67
|
+
function clampInt(value, fallback, min, max) {
|
|
68
|
+
if (value === undefined || value === null || value === '') return fallback
|
|
69
|
+
const numeric = Number(value)
|
|
70
|
+
if (!Number.isFinite(numeric)) return fallback
|
|
71
|
+
return Math.min(max, Math.max(min, Math.round(numeric)))
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Convert milliseconds to ISO text.
|
|
76
|
+
* @param {number} ms - epoch milliseconds.
|
|
77
|
+
* @returns {string} ISO timestamp.
|
|
78
|
+
*/
|
|
79
|
+
function iso(ms) {
|
|
80
|
+
return new Date(ms).toISOString()
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Format a compact UTC stamp such as `20261106T131500Z`.
|
|
85
|
+
* @param {number} ms - epoch milliseconds.
|
|
86
|
+
* @returns {string} compact stamp.
|
|
87
|
+
*/
|
|
88
|
+
function compactStamp(ms) {
|
|
89
|
+
return new Date(ms).toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z')
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Keep the last `max` characters of one output string.
|
|
94
|
+
* @param {unknown} value - raw output.
|
|
95
|
+
* @param {number} max - maximum length.
|
|
96
|
+
* @returns {string} tail.
|
|
97
|
+
*/
|
|
98
|
+
function tail(value, max = TAIL_MAX_LENGTH) {
|
|
99
|
+
if (typeof value !== 'string') return ''
|
|
100
|
+
return value.length <= max ? value : value.slice(value.length - max)
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Truncate one command for display.
|
|
105
|
+
* @param {unknown} value - raw command.
|
|
106
|
+
* @param {number} max - maximum length.
|
|
107
|
+
* @returns {string} command text.
|
|
108
|
+
*/
|
|
109
|
+
function cut(value, max = COMMAND_MAX_LENGTH) {
|
|
110
|
+
if (typeof value !== 'string') return ''
|
|
111
|
+
return value.length <= max ? value : `${value.slice(0, max)}…`
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Whether `candidate` is the directory itself or lies below it.
|
|
116
|
+
* @param {string} parent - parent directory.
|
|
117
|
+
* @param {string} candidate - child path.
|
|
118
|
+
* @returns {boolean} containment.
|
|
119
|
+
*/
|
|
120
|
+
function isInside(parent, candidate) {
|
|
121
|
+
if (typeof parent !== 'string' || typeof candidate !== 'string') return false
|
|
122
|
+
return candidate === parent || candidate.startsWith(`${parent}/`)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Extract one SSH error message safely.
|
|
127
|
+
* @param {unknown} error - thrown value.
|
|
128
|
+
* @returns {string} message.
|
|
129
|
+
*/
|
|
130
|
+
function errorMessage(error) {
|
|
131
|
+
if (error && typeof error.message === 'string' && error.message !== '') return error.message
|
|
132
|
+
return '未知错误'
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Parse the `###FREE_KB` section appended to the preflight command.
|
|
137
|
+
* @param {string} text - command output.
|
|
138
|
+
* @returns {number} available kilobytes (0 when unknown).
|
|
139
|
+
*/
|
|
140
|
+
function parseFreeKB(text) {
|
|
141
|
+
const match = /###FREE_KB\r?\n([^\r\n]*)/.exec(String(text ?? ''))
|
|
142
|
+
const value = match === null ? Number.NaN : Number.parseInt(match[1].trim(), 10)
|
|
143
|
+
return Number.isFinite(value) && value >= 0 ? value : 0
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Run the read-only preflight for one app directory.
|
|
148
|
+
* @param {object} account - full account record.
|
|
149
|
+
* @param {string} appDir - validated absolute path.
|
|
150
|
+
* @param {number} timeoutMs - command timeout.
|
|
151
|
+
* @returns {Promise<object>} preflight facts.
|
|
152
|
+
*/
|
|
153
|
+
async function runPreflight(ssh, account, appDir, timeoutMs) {
|
|
154
|
+
const command = [
|
|
155
|
+
buildInspectCommand(appDir),
|
|
156
|
+
"printf '\\n###FREE_KB\\n'",
|
|
157
|
+
`__dir=${shQuote(appDir)}`,
|
|
158
|
+
'while [ ! -d "$__dir" ] && [ "$__dir" != "/" ]; do __dir=$(dirname "$__dir"); done',
|
|
159
|
+
'df -Pk "$__dir" 2>/dev/null | awk \'NR==2 {print $4}\'',
|
|
160
|
+
].join('\n')
|
|
161
|
+
const result = await ssh.exec(account, command, { timeoutMs })
|
|
162
|
+
const parsed = parseInspectOutput(result.stdout)
|
|
163
|
+
return {
|
|
164
|
+
exists: parsed.exists,
|
|
165
|
+
writable: parsed.writable,
|
|
166
|
+
physicalPath: parsed.physicalPath,
|
|
167
|
+
sizeKB: parsed.sizeKB,
|
|
168
|
+
freeKB: parseFreeKB(result.stdout),
|
|
169
|
+
markers: parsed.markers,
|
|
170
|
+
git: parsed.git,
|
|
171
|
+
serviceUnits: parsed.serviceUnits,
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/**
|
|
176
|
+
* Collect blockers for one plan/apply attempt.
|
|
177
|
+
* @param {{ appDir: string, preflight: object, updateCommand: string, backup: { enabled: boolean, dir: string } }} input - facts.
|
|
178
|
+
* @returns {string[]} blockers.
|
|
179
|
+
*/
|
|
180
|
+
function collectBlockers({ appDir, preflight, updateCommand, backup }) {
|
|
181
|
+
const blockers = []
|
|
182
|
+
if (!preflight.exists) blockers.push('The app directory does not exist or is not a directory.')
|
|
183
|
+
if (!preflight.writable) blockers.push('The app directory is not writable by the SSH user.')
|
|
184
|
+
if (typeof updateCommand !== 'string' || updateCommand.trim() === '') blockers.push('The update command is empty.')
|
|
185
|
+
if (backup.enabled) {
|
|
186
|
+
if (isInside(appDir, backup.dir)) blockers.push('The backup directory is inside the app directory.')
|
|
187
|
+
const required = Math.ceil(preflight.sizeKB * 1.2) + BACKUP_HEADROOM_KB
|
|
188
|
+
if (!(preflight.freeKB >= required)) {
|
|
189
|
+
blockers.push(`Not enough free disk space for a backup: need about ${required} KB, have ${preflight.freeKB} KB.`)
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return blockers
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* Collect non-blocking warnings for one plan.
|
|
197
|
+
* @param {{ resource: object, appDir: string, preflight: object, backup: { enabled: boolean, dir: string } }} input - facts.
|
|
198
|
+
* @returns {string[]} warnings.
|
|
199
|
+
*/
|
|
200
|
+
function collectWarnings({ resource, appDir, preflight, backup }) {
|
|
201
|
+
const warnings = []
|
|
202
|
+
if (preflight.physicalPath !== '' && preflight.physicalPath !== appDir) {
|
|
203
|
+
warnings.push(`The app directory is a symlink to ${preflight.physicalPath}.`)
|
|
204
|
+
}
|
|
205
|
+
const discovered = resource && typeof resource.discovered === 'object' && resource.discovered !== null ? resource.discovered : undefined
|
|
206
|
+
if (discovered) {
|
|
207
|
+
if (Array.isArray(discovered.markers)) {
|
|
208
|
+
const before = discovered.markers.slice().sort().join('\n')
|
|
209
|
+
const nowMarkers = Array.isArray(preflight.markers) ? preflight.markers.slice().sort().join('\n') : ''
|
|
210
|
+
if (before !== nowMarkers) warnings.push('Project markers changed since the last discovery.')
|
|
211
|
+
}
|
|
212
|
+
const gitKnown = typeof discovered.gitRemote === 'string' || typeof discovered.gitBranch === 'string'
|
|
213
|
+
if (gitKnown) {
|
|
214
|
+
const beforeGit = `${discovered.gitRemote ?? ''}\n${discovered.gitBranch ?? ''}`
|
|
215
|
+
const nowGit = preflight.git ? `${preflight.git.remote}\n${preflight.git.branch}` : '\n'
|
|
216
|
+
if (beforeGit !== nowGit) warnings.push('Git remote/branch changed since the last discovery.')
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
if (!backup.enabled) warnings.push('Backup is disabled for this resource.')
|
|
220
|
+
for (const warning of remotePathWarnings(appDir)) {
|
|
221
|
+
if (!warnings.includes(warning)) warnings.push(warning)
|
|
222
|
+
}
|
|
223
|
+
if (backup.enabled && backup.dir !== '') {
|
|
224
|
+
for (const warning of remotePathWarnings(backup.dir)) {
|
|
225
|
+
const text = `Backup: ${warning}`
|
|
226
|
+
if (!warnings.includes(text)) warnings.push(text)
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return warnings
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Build the human summary for a plan.
|
|
234
|
+
* @param {{ resourceName: string, serverName: string, appDir: string, updateCommand: string, backup: object }} plan - plan fields.
|
|
235
|
+
* @returns {{ en: string, zh: string }} summary.
|
|
236
|
+
*/
|
|
237
|
+
function buildSummary({ resourceName, serverName, appDir, updateCommand, backup }) {
|
|
238
|
+
const command = cut(updateCommand, 300)
|
|
239
|
+
const backupEn = backup.enabled ? `Back up to ${backup.file} first. ` : 'Backup is disabled. '
|
|
240
|
+
const backupZh = backup.enabled ? `先备份到 ${backup.file}。` : '未启用备份。'
|
|
241
|
+
return {
|
|
242
|
+
en: `Update "${resourceName}" on ${serverName} in ${appDir}. ${backupEn}Then run: ${command}`,
|
|
243
|
+
zh: `在 ${serverName} 上更新 ${resourceName}(${appDir})。${backupZh}随后执行:${command}`,
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Create the update service.
|
|
249
|
+
* @param {{ ssh: object, resources: object, history: object, now?: () => number }} options - services.
|
|
250
|
+
* @returns {{
|
|
251
|
+
* plan: Function,
|
|
252
|
+
* peekPlan: Function,
|
|
253
|
+
* apply: Function,
|
|
254
|
+
* rollback: Function,
|
|
255
|
+
* resolveRollbackTarget: Function,
|
|
256
|
+
* }} update service.
|
|
257
|
+
*/
|
|
258
|
+
export function createUpdateService({ ssh, resources, history, now = () => Date.now() } = {}) {
|
|
259
|
+
if (!ssh || typeof ssh.exec !== 'function') throw new TypeError('createUpdateService: ssh client is required')
|
|
260
|
+
if (!resources || typeof resources.get !== 'function' || typeof resources.find !== 'function') {
|
|
261
|
+
throw new TypeError('createUpdateService: resources store is required')
|
|
262
|
+
}
|
|
263
|
+
if (!history || typeof history.append !== 'function' || typeof history.list !== 'function') {
|
|
264
|
+
throw new TypeError('createUpdateService: history store is required')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** @type {Map<string, object>} cached plans, insertion ordered. */
|
|
268
|
+
const plans = new Map()
|
|
269
|
+
/** @type {Map<string, object>} pinned rollback targets, insertion ordered. */
|
|
270
|
+
const rollbacks = new Map()
|
|
271
|
+
/** @type {Set<string>} servers with an apply/rollback in flight. */
|
|
272
|
+
const busyServers = new Set()
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* Normalize the agent id used to bind plans.
|
|
276
|
+
* @param {unknown} agentId - raw id.
|
|
277
|
+
* @returns {string} normalized id.
|
|
278
|
+
*/
|
|
279
|
+
function agentKey(agentId) {
|
|
280
|
+
return typeof agentId === 'string' ? agentId : ''
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** Drop expired plans and enforce the cache cap. */
|
|
284
|
+
function prunePlans() {
|
|
285
|
+
const at = now()
|
|
286
|
+
for (const [id, plan] of plans) {
|
|
287
|
+
const expires = Date.parse(plan.expiresAt)
|
|
288
|
+
if (!Number.isFinite(expires) || expires <= at) plans.delete(id)
|
|
289
|
+
}
|
|
290
|
+
while (plans.size > PLAN_MAX) {
|
|
291
|
+
const oldest = plans.keys().next().value
|
|
292
|
+
if (oldest === undefined) break
|
|
293
|
+
plans.delete(oldest)
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* Clone a plan for a caller that must not mutate the cache.
|
|
299
|
+
* @param {object} plan - cached plan.
|
|
300
|
+
* @returns {object} clone.
|
|
301
|
+
*/
|
|
302
|
+
function clonePlan(plan) {
|
|
303
|
+
return JSON.parse(JSON.stringify(plan))
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve the latest usable backup history entry for one resource.
|
|
308
|
+
* @param {string | object} resourceQuery - resource id/name query or public view.
|
|
309
|
+
* @param {string} [historyId] - explicit history entry.
|
|
310
|
+
* @returns {object | undefined} rollback target.
|
|
311
|
+
*/
|
|
312
|
+
function resolveRollbackTarget(resourceQuery, historyId) {
|
|
313
|
+
const resource =
|
|
314
|
+
typeof resourceQuery === 'string'
|
|
315
|
+
? resources.find(resourceQuery)
|
|
316
|
+
: resourceQuery && typeof resourceQuery === 'object' && typeof resourceQuery.id === 'string'
|
|
317
|
+
? (resources.get(resourceQuery.id) ?? resourceQuery)
|
|
318
|
+
: undefined
|
|
319
|
+
if (!resource || typeof resource.appDir !== 'string' || resource.appDir === '') return undefined
|
|
320
|
+
|
|
321
|
+
let layout
|
|
322
|
+
try {
|
|
323
|
+
layout = backupLayout({ appDir: resource.appDir, backupDir: resource.backupDir, name: resource.name, timestamp: now() })
|
|
324
|
+
} catch {
|
|
325
|
+
return undefined
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** @type {object | undefined} */
|
|
329
|
+
let entry
|
|
330
|
+
if (typeof historyId === 'string' && historyId !== '') {
|
|
331
|
+
const candidate = history.get(historyId)
|
|
332
|
+
if (
|
|
333
|
+
candidate &&
|
|
334
|
+
candidate.resourceId === resource.id &&
|
|
335
|
+
candidate.action === 'apply' &&
|
|
336
|
+
typeof candidate.backupPath === 'string' &&
|
|
337
|
+
candidate.backupPath !== '' &&
|
|
338
|
+
candidate.backupPath.startsWith(`${layout.dir}/`)
|
|
339
|
+
) {
|
|
340
|
+
entry = candidate
|
|
341
|
+
}
|
|
342
|
+
} else {
|
|
343
|
+
entry = history
|
|
344
|
+
.list({ resourceId: resource.id, limit: 200 })
|
|
345
|
+
.find(
|
|
346
|
+
(candidate) =>
|
|
347
|
+
candidate.action === 'apply' &&
|
|
348
|
+
typeof candidate.backupPath === 'string' &&
|
|
349
|
+
candidate.backupPath !== '' &&
|
|
350
|
+
candidate.backupPath.startsWith(`${layout.dir}/`),
|
|
351
|
+
)
|
|
352
|
+
}
|
|
353
|
+
if (!entry || typeof entry.backupPath !== 'string' || entry.backupPath === '') return undefined
|
|
354
|
+
return {
|
|
355
|
+
resource,
|
|
356
|
+
entry,
|
|
357
|
+
backupPath: entry.backupPath,
|
|
358
|
+
backupDir: layout.dir,
|
|
359
|
+
parent: layout.parent,
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
/** Drop expired rollback pins and enforce the cache cap. */
|
|
364
|
+
function pruneRollbacks() {
|
|
365
|
+
const at = now()
|
|
366
|
+
for (const [id, ticket] of rollbacks) {
|
|
367
|
+
if (!Number.isFinite(ticket.expiresAt) || ticket.expiresAt <= at) rollbacks.delete(id)
|
|
368
|
+
}
|
|
369
|
+
while (rollbacks.size > ROLLBACK_MAX) {
|
|
370
|
+
const oldest = rollbacks.keys().next().value
|
|
371
|
+
if (oldest === undefined) break
|
|
372
|
+
rollbacks.delete(oldest)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* Pin one resolved rollback target so the body restores exactly what was approved.
|
|
378
|
+
*
|
|
379
|
+
* Approval is a two-phase operation: the gate resolves a target to build the
|
|
380
|
+
* prompt, the operator approves, and only then does the tool body restore.
|
|
381
|
+
* Re-resolving in the body would let any apply/upload landing in between
|
|
382
|
+
* change which archive gets extracted, so the resolved entry is pinned here
|
|
383
|
+
* and consumed verbatim by {@link rollback}.
|
|
384
|
+
*
|
|
385
|
+
* The pin is keyed by agent and resource because the harness gate cannot
|
|
386
|
+
* rewrite tool arguments between the approval prompt and the tool body.
|
|
387
|
+
*
|
|
388
|
+
* @param {object} target - value from {@link resolveRollbackTarget}.
|
|
389
|
+
* @param {string | undefined} agentId - agent that must consume the pin.
|
|
390
|
+
* @returns {string} pin key.
|
|
391
|
+
*/
|
|
392
|
+
function pinRollback(target, agentId) {
|
|
393
|
+
pruneRollbacks()
|
|
394
|
+
const key = `${agentKey(agentId)}\u0000${target.resource.id}`
|
|
395
|
+
rollbacks.set(key, {
|
|
396
|
+
key,
|
|
397
|
+
resourceId: target.resource.id,
|
|
398
|
+
serverId: target.resource.serverId,
|
|
399
|
+
appDir: target.resource.appDir,
|
|
400
|
+
entryId: target.entry && typeof target.entry.id === 'string' ? target.entry.id : '',
|
|
401
|
+
backupPath: target.backupPath,
|
|
402
|
+
parent: target.parent,
|
|
403
|
+
expiresAt: now() + PLAN_TTL_MS,
|
|
404
|
+
})
|
|
405
|
+
return key
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/**
|
|
409
|
+
* Read the pin for one agent and resource without consuming it.
|
|
410
|
+
* @param {string | undefined} agentId - calling agent id.
|
|
411
|
+
* @param {string} resourceId - resource id.
|
|
412
|
+
* @returns {object | undefined} pinned target.
|
|
413
|
+
*/
|
|
414
|
+
function peekRollback(agentId, resourceId) {
|
|
415
|
+
pruneRollbacks()
|
|
416
|
+
if (typeof resourceId !== 'string' || resourceId === '') return undefined
|
|
417
|
+
return rollbacks.get(`${agentKey(agentId)}\u0000${resourceId}`)
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* Drop one pin.
|
|
422
|
+
* @param {string | undefined} agentId - calling agent id.
|
|
423
|
+
* @param {string} resourceId - resource id.
|
|
424
|
+
*/
|
|
425
|
+
function clearRollback(agentId, resourceId) {
|
|
426
|
+
rollbacks.delete(`${agentKey(agentId)}\u0000${resourceId}`)
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Build a plan without touching the remote host beyond read-only preflight.
|
|
431
|
+
* @param {object} account - full account record.
|
|
432
|
+
* @param {object} resource - resource public view.
|
|
433
|
+
* @param {{ agentId?: string, timeoutMs?: number }} [options] - plan options.
|
|
434
|
+
* @returns {Promise<object>} cached plan.
|
|
435
|
+
*/
|
|
436
|
+
async function plan(account, resource, { agentId, timeoutMs } = {}) {
|
|
437
|
+
if (!account || typeof account !== 'object') throw invalid('缺少服务器账户')
|
|
438
|
+
if (!resource || typeof resource !== 'object') throw invalid('缺少资源')
|
|
439
|
+
if (typeof resource.id !== 'string' || resource.id === '') throw invalid('资源 id 无效')
|
|
440
|
+
if (typeof resource.serverId !== 'string' || resource.serverId === '') throw invalid('资源缺少服务器')
|
|
441
|
+
const appDir = validateRemotePath(resource.appDir)
|
|
442
|
+
const preflightTimeout = clampInt(timeoutMs, DEFAULT_PREFLIGHT_TIMEOUT_MS, MIN_TIMEOUT_MS, MAX_TIMEOUT_MS)
|
|
443
|
+
const layout = backupLayout({ appDir, backupDir: resource.backupDir, name: resource.name, timestamp: now() })
|
|
444
|
+
const preflight = await runPreflight(ssh, account, appDir, preflightTimeout)
|
|
445
|
+
const updateCommand = typeof resource.updateCommand === 'string' ? resource.updateCommand.trim() : ''
|
|
446
|
+
const backup = { enabled: resource.backupEnabled !== false, dir: layout.dir, file: layout.file }
|
|
447
|
+
const blockers = collectBlockers({ appDir, preflight, updateCommand, backup })
|
|
448
|
+
const warnings = collectWarnings({ resource, appDir, preflight, backup })
|
|
449
|
+
|
|
450
|
+
const restartCommand = typeof resource.restartCommand === 'string' ? resource.restartCommand.trim() : ''
|
|
451
|
+
const verifyCommand = typeof resource.verifyCommand === 'string' ? resource.verifyCommand.trim() : ''
|
|
452
|
+
const backupKeep = clampInt(resource.backupKeep, 5, 1, 50)
|
|
453
|
+
const backupParent = path.posix.dirname(appDir)
|
|
454
|
+
const backupBase = path.posix.basename(appDir)
|
|
455
|
+
|
|
456
|
+
const steps = [{ id: 'preflight', title: 'Preflight checks', detail: `Inspect ${appDir} (read-only).` }]
|
|
457
|
+
if (backup.enabled) {
|
|
458
|
+
steps.push({
|
|
459
|
+
id: 'backup',
|
|
460
|
+
title: 'Back up the current directory',
|
|
461
|
+
detail: cut(`mkdir -p ${layout.dir} && tar -C ${backupParent} -czf ${layout.file} ./${backupBase}`, 500),
|
|
462
|
+
})
|
|
463
|
+
}
|
|
464
|
+
steps.push({ id: 'update', title: 'Run the update command', detail: cut(`cd ${appDir} && bash -lc ${shQuote(updateCommand)}`, 500) })
|
|
465
|
+
if (restartCommand !== '') {
|
|
466
|
+
steps.push({ id: 'restart', title: 'Restart the service', detail: cut(`cd ${appDir} && bash -lc ${shQuote(restartCommand)}`, 500) })
|
|
467
|
+
}
|
|
468
|
+
if (verifyCommand !== '') {
|
|
469
|
+
steps.push({ id: 'verify', title: 'Verify the deployment', detail: cut(`cd ${appDir} && bash -lc ${shQuote(verifyCommand)}`, 500) })
|
|
470
|
+
}
|
|
471
|
+
if (backup.enabled) {
|
|
472
|
+
steps.push({ id: 'prune', title: 'Prune old backups', detail: `Keep the newest ${backupKeep} backup archives in ${layout.dir}.` })
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
const createdAt = now()
|
|
476
|
+
const planId = randomUUID()
|
|
477
|
+
const serverName =
|
|
478
|
+
typeof resource.serverName === 'string' && resource.serverName !== ''
|
|
479
|
+
? resource.serverName
|
|
480
|
+
: typeof account.name === 'string'
|
|
481
|
+
? account.name
|
|
482
|
+
: ''
|
|
483
|
+
const result = {
|
|
484
|
+
id: planId,
|
|
485
|
+
agentId: agentKey(agentId),
|
|
486
|
+
resourceId: resource.id,
|
|
487
|
+
resourceName: typeof resource.name === 'string' ? resource.name : '',
|
|
488
|
+
serverId: resource.serverId,
|
|
489
|
+
serverName,
|
|
490
|
+
appDir,
|
|
491
|
+
updateCommand,
|
|
492
|
+
backup,
|
|
493
|
+
preflight,
|
|
494
|
+
steps,
|
|
495
|
+
warnings,
|
|
496
|
+
blockers,
|
|
497
|
+
createdAt: iso(createdAt),
|
|
498
|
+
expiresAt: iso(createdAt + PLAN_TTL_MS),
|
|
499
|
+
summary: buildSummary({
|
|
500
|
+
resourceName: typeof resource.name === 'string' ? resource.name : '',
|
|
501
|
+
serverName,
|
|
502
|
+
appDir,
|
|
503
|
+
updateCommand,
|
|
504
|
+
backup,
|
|
505
|
+
}),
|
|
506
|
+
}
|
|
507
|
+
if (restartCommand !== '') result.restartCommand = restartCommand
|
|
508
|
+
if (verifyCommand !== '') result.verifyCommand = verifyCommand
|
|
509
|
+
|
|
510
|
+
plans.set(planId, result)
|
|
511
|
+
prunePlans()
|
|
512
|
+
return result
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* Read one cached plan without consuming it.
|
|
517
|
+
* @param {string} planId - plan id.
|
|
518
|
+
* @param {string | undefined} agentId - calling agent id.
|
|
519
|
+
* @returns {object | undefined} plan copy.
|
|
520
|
+
*/
|
|
521
|
+
function peekPlan(planId, agentId) {
|
|
522
|
+
prunePlans()
|
|
523
|
+
if (typeof planId !== 'string' || planId === '') return undefined
|
|
524
|
+
const plan = plans.get(planId)
|
|
525
|
+
if (plan === undefined) return undefined
|
|
526
|
+
if (plan.agentId !== agentKey(agentId)) return undefined
|
|
527
|
+
if (Date.parse(plan.expiresAt) <= now()) {
|
|
528
|
+
plans.delete(planId)
|
|
529
|
+
return undefined
|
|
530
|
+
}
|
|
531
|
+
return clonePlan(plan)
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* Execute one remote command as a history step.
|
|
536
|
+
* @param {object} account - full account record.
|
|
537
|
+
* @param {{ id: string, title: string, command: string }} definition - step definition.
|
|
538
|
+
* @param {number} timeoutMs - command timeout.
|
|
539
|
+
* @returns {Promise<object>} history step.
|
|
540
|
+
*/
|
|
541
|
+
async function runCommandStep(account, definition, timeoutMs) {
|
|
542
|
+
const startedAt = now()
|
|
543
|
+
try {
|
|
544
|
+
const result = await ssh.exec(account, definition.command, { timeoutMs })
|
|
545
|
+
const stdoutTail = tail(result.stdout)
|
|
546
|
+
const stderrTail = tail(result.stderr)
|
|
547
|
+
return {
|
|
548
|
+
id: definition.id,
|
|
549
|
+
title: definition.title,
|
|
550
|
+
command: cut(definition.command),
|
|
551
|
+
status: result.exitCode === 0 ? 'success' : 'failed',
|
|
552
|
+
exitCode: Number.isInteger(result.exitCode) ? result.exitCode : null,
|
|
553
|
+
durationMs: typeof result.durationMs === 'number' && Number.isFinite(result.durationMs) ? Math.max(0, result.durationMs) : Math.max(0, now() - startedAt),
|
|
554
|
+
stdoutTail,
|
|
555
|
+
stderrTail,
|
|
556
|
+
truncated:
|
|
557
|
+
result.truncated === true ||
|
|
558
|
+
(typeof result.stdout === 'string' && result.stdout.length > TAIL_MAX_LENGTH) ||
|
|
559
|
+
(typeof result.stderr === 'string' && result.stderr.length > TAIL_MAX_LENGTH),
|
|
560
|
+
}
|
|
561
|
+
} catch (error) {
|
|
562
|
+
return {
|
|
563
|
+
id: definition.id,
|
|
564
|
+
title: definition.title,
|
|
565
|
+
command: cut(definition.command),
|
|
566
|
+
status: 'failed',
|
|
567
|
+
exitCode: null,
|
|
568
|
+
durationMs: Math.max(0, now() - startedAt),
|
|
569
|
+
stdoutTail: '',
|
|
570
|
+
stderrTail: errorMessage(error),
|
|
571
|
+
truncated: false,
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
/**
|
|
577
|
+
* Run one backup/update/restart/verify/prune sequence.
|
|
578
|
+
* @param {object} account - full account record.
|
|
579
|
+
* @param {object} plan - cached plan.
|
|
580
|
+
* @param {object} resource - current resource view.
|
|
581
|
+
* @returns {Promise<object>} stored history entry.
|
|
582
|
+
*/
|
|
583
|
+
async function executeApply(account, plan, resource) {
|
|
584
|
+
const startedAt = now()
|
|
585
|
+
const steps = []
|
|
586
|
+
let backupPath = ''
|
|
587
|
+
const timeoutMs = clampInt(resource.timeoutSeconds, 600, 30, 3600) * 1000
|
|
588
|
+
const serverName =
|
|
589
|
+
typeof resource.serverName === 'string' && resource.serverName !== ''
|
|
590
|
+
? resource.serverName
|
|
591
|
+
: typeof account.name === 'string'
|
|
592
|
+
? account.name
|
|
593
|
+
: ''
|
|
594
|
+
/** @type {string} */
|
|
595
|
+
let message = ''
|
|
596
|
+
|
|
597
|
+
/**
|
|
598
|
+
* Persist the attempt and return its history entry.
|
|
599
|
+
* @param {string} status - final status.
|
|
600
|
+
* @param {string} [note] - final message.
|
|
601
|
+
* @returns {Promise<object>} stored entry.
|
|
602
|
+
*/
|
|
603
|
+
const finish = async (status, note) => {
|
|
604
|
+
if (typeof note === 'string' && note !== '') message = note
|
|
605
|
+
const finishedAt = now()
|
|
606
|
+
const entry = await history.append({
|
|
607
|
+
action: 'apply',
|
|
608
|
+
resourceId: plan.resourceId,
|
|
609
|
+
resourceName: plan.resourceName,
|
|
610
|
+
serverId: plan.serverId,
|
|
611
|
+
serverName,
|
|
612
|
+
appDir: plan.appDir,
|
|
613
|
+
backupPath,
|
|
614
|
+
status,
|
|
615
|
+
startedAt: iso(startedAt),
|
|
616
|
+
finishedAt: iso(finishedAt),
|
|
617
|
+
durationMs: Math.max(0, finishedAt - startedAt),
|
|
618
|
+
steps,
|
|
619
|
+
...(message === '' ? {} : { message }),
|
|
620
|
+
})
|
|
621
|
+
await resources.markUpdated(plan.resourceId, { at: entry.finishedAt, status, backupPath })
|
|
622
|
+
return entry
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
try {
|
|
626
|
+
let preflight
|
|
627
|
+
const preflightStarted = now()
|
|
628
|
+
try {
|
|
629
|
+
preflight = await runPreflight(ssh, account, plan.appDir, Math.min(timeoutMs, DEFAULT_PREFLIGHT_TIMEOUT_MS))
|
|
630
|
+
} catch (error) {
|
|
631
|
+
steps.push({
|
|
632
|
+
id: 'preflight',
|
|
633
|
+
title: 'Preflight checks',
|
|
634
|
+
status: 'failed',
|
|
635
|
+
exitCode: null,
|
|
636
|
+
durationMs: Math.max(0, now() - preflightStarted),
|
|
637
|
+
stdoutTail: '',
|
|
638
|
+
stderrTail: errorMessage(error),
|
|
639
|
+
truncated: false,
|
|
640
|
+
})
|
|
641
|
+
return await finish('failed', '预检失败,未执行任何远端变更。')
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
const blockers = collectBlockers({
|
|
645
|
+
appDir: plan.appDir,
|
|
646
|
+
preflight,
|
|
647
|
+
updateCommand: plan.updateCommand,
|
|
648
|
+
backup: plan.backup,
|
|
649
|
+
})
|
|
650
|
+
if (blockers.length > 0) {
|
|
651
|
+
steps.push({
|
|
652
|
+
id: 'preflight',
|
|
653
|
+
title: 'Preflight checks',
|
|
654
|
+
status: 'failed',
|
|
655
|
+
exitCode: null,
|
|
656
|
+
durationMs: Math.max(0, now() - preflightStarted),
|
|
657
|
+
stdoutTail: '',
|
|
658
|
+
stderrTail: blockers.join('\n'),
|
|
659
|
+
truncated: false,
|
|
660
|
+
})
|
|
661
|
+
return await finish('blocked', '预检发现阻塞项,未执行任何远端变更。')
|
|
662
|
+
}
|
|
663
|
+
steps.push({
|
|
664
|
+
id: 'preflight',
|
|
665
|
+
title: 'Preflight checks',
|
|
666
|
+
status: 'success',
|
|
667
|
+
exitCode: null,
|
|
668
|
+
durationMs: Math.max(0, now() - preflightStarted),
|
|
669
|
+
stdoutTail: '',
|
|
670
|
+
stderrTail: '',
|
|
671
|
+
truncated: false,
|
|
672
|
+
})
|
|
673
|
+
|
|
674
|
+
const parent = path.posix.dirname(plan.appDir)
|
|
675
|
+
const baseName = path.posix.basename(plan.appDir)
|
|
676
|
+
|
|
677
|
+
if (plan.backup.enabled) {
|
|
678
|
+
backupPath = plan.backup.file
|
|
679
|
+
const command = [
|
|
680
|
+
`mkdir -p ${shQuote(plan.backup.dir)}`,
|
|
681
|
+
`tar -C ${shQuote(parent)} -czf ${shQuote(plan.backup.file)} ${shQuote(`./${baseName}`)}`,
|
|
682
|
+
`test -s ${shQuote(plan.backup.file)}`,
|
|
683
|
+
].join(' && ')
|
|
684
|
+
const step = await runCommandStep(account, { id: 'backup', title: 'Back up the current directory', command }, timeoutMs)
|
|
685
|
+
steps.push(step)
|
|
686
|
+
if (step.status !== 'success') return await finish('failed', '备份失败,更新未执行;备份文件(如有)已保留。')
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
const updateStep = await runCommandStep(
|
|
690
|
+
account,
|
|
691
|
+
{
|
|
692
|
+
id: 'update',
|
|
693
|
+
title: 'Run the update command',
|
|
694
|
+
command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.updateCommand)}`,
|
|
695
|
+
},
|
|
696
|
+
timeoutMs,
|
|
697
|
+
)
|
|
698
|
+
steps.push(updateStep)
|
|
699
|
+
if (updateStep.status !== 'success') return await finish('failed', '更新命令执行失败;备份已保留,且不会自动回滚。')
|
|
700
|
+
|
|
701
|
+
if (typeof plan.restartCommand === 'string' && plan.restartCommand !== '') {
|
|
702
|
+
const restartStep = await runCommandStep(
|
|
703
|
+
account,
|
|
704
|
+
{
|
|
705
|
+
id: 'restart',
|
|
706
|
+
title: 'Restart the service',
|
|
707
|
+
command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.restartCommand)}`,
|
|
708
|
+
},
|
|
709
|
+
timeoutMs,
|
|
710
|
+
)
|
|
711
|
+
steps.push(restartStep)
|
|
712
|
+
if (restartStep.status !== 'success') return await finish('partial', '更新已执行,但重启命令失败。')
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (typeof plan.verifyCommand === 'string' && plan.verifyCommand !== '') {
|
|
716
|
+
const verifyStep = await runCommandStep(
|
|
717
|
+
account,
|
|
718
|
+
{
|
|
719
|
+
id: 'verify',
|
|
720
|
+
title: 'Verify the deployment',
|
|
721
|
+
command: `cd ${shQuote(plan.appDir)} && bash -lc ${shQuote(plan.verifyCommand)}`,
|
|
722
|
+
},
|
|
723
|
+
timeoutMs,
|
|
724
|
+
)
|
|
725
|
+
steps.push(verifyStep)
|
|
726
|
+
if (verifyStep.status !== 'success') return await finish('partial', '更新已执行,但校验命令失败。')
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
if (plan.backup.enabled) {
|
|
730
|
+
const keep = clampInt(resource.backupKeep, 5, 1, 50)
|
|
731
|
+
const segment = safeSegment(plan.resourceName)
|
|
732
|
+
const pattern = `${shQuote(`${plan.backup.dir}/${segment}-`)}*.tar.gz`
|
|
733
|
+
const command = `ls -1 ${pattern} 2>/dev/null | sort -r | tail -n +${keep + 1} | while IFS= read -r __f; do rm -f "$__f"; done`
|
|
734
|
+
const pruneStep = await runCommandStep(account, { id: 'prune', title: 'Prune old backups', command }, timeoutMs)
|
|
735
|
+
steps.push(pruneStep)
|
|
736
|
+
if (pruneStep.status !== 'success') return await finish('partial', '更新完成,但清理旧备份失败。')
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
return await finish('success', '更新完成。')
|
|
740
|
+
} catch (error) {
|
|
741
|
+
steps.push({
|
|
742
|
+
id: 'internal',
|
|
743
|
+
title: 'Unexpected failure',
|
|
744
|
+
status: 'failed',
|
|
745
|
+
exitCode: null,
|
|
746
|
+
durationMs: 0,
|
|
747
|
+
stdoutTail: '',
|
|
748
|
+
stderrTail: errorMessage(error),
|
|
749
|
+
truncated: false,
|
|
750
|
+
})
|
|
751
|
+
return await finish('failed', '更新流程异常终止。')
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
/**
|
|
756
|
+
* Execute one rollback attempt.
|
|
757
|
+
* @param {object} account - full account record.
|
|
758
|
+
* @param {object} target - rollback target from `resolveRollbackTarget`.
|
|
759
|
+
* @returns {Promise<object>} stored history entry.
|
|
760
|
+
*/
|
|
761
|
+
async function executeRollback(account, target) {
|
|
762
|
+
const { resource, entry } = target
|
|
763
|
+
const startedAt = now()
|
|
764
|
+
const steps = []
|
|
765
|
+
const timeoutMs = clampInt(resource.timeoutSeconds, 600, 30, 3600) * 1000
|
|
766
|
+
const serverName =
|
|
767
|
+
typeof resource.serverName === 'string' && resource.serverName !== ''
|
|
768
|
+
? resource.serverName
|
|
769
|
+
: typeof account.name === 'string'
|
|
770
|
+
? account.name
|
|
771
|
+
: ''
|
|
772
|
+
const stamp = compactStamp(now())
|
|
773
|
+
const previousDir = `${resource.appDir}.ssh-helper-before-rollback-${stamp}`
|
|
774
|
+
/** @type {string} */
|
|
775
|
+
let message = ''
|
|
776
|
+
|
|
777
|
+
/**
|
|
778
|
+
* Persist the rollback attempt.
|
|
779
|
+
* @param {string} status - final status.
|
|
780
|
+
* @param {string} [note] - final message.
|
|
781
|
+
* @returns {Promise<object>} stored entry.
|
|
782
|
+
*/
|
|
783
|
+
const finish = async (status, note) => {
|
|
784
|
+
if (typeof note === 'string' && note !== '') message = note
|
|
785
|
+
const finishedAt = now()
|
|
786
|
+
const stored = await history.append({
|
|
787
|
+
action: 'rollback',
|
|
788
|
+
resourceId: resource.id,
|
|
789
|
+
resourceName: resource.name,
|
|
790
|
+
serverId: resource.serverId,
|
|
791
|
+
serverName,
|
|
792
|
+
appDir: resource.appDir,
|
|
793
|
+
backupPath: target.backupPath,
|
|
794
|
+
status,
|
|
795
|
+
startedAt: iso(startedAt),
|
|
796
|
+
finishedAt: iso(finishedAt),
|
|
797
|
+
durationMs: Math.max(0, finishedAt - startedAt),
|
|
798
|
+
steps,
|
|
799
|
+
...(message === '' ? {} : { message }),
|
|
800
|
+
})
|
|
801
|
+
return stored
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
try {
|
|
805
|
+
const backupStep = await runCommandStep(
|
|
806
|
+
account,
|
|
807
|
+
{ id: 'backup-check', title: 'Verify the backup archive', command: `test -s ${shQuote(target.backupPath)}` },
|
|
808
|
+
timeoutMs,
|
|
809
|
+
)
|
|
810
|
+
steps.push(backupStep)
|
|
811
|
+
if (backupStep.status !== 'success') return await finish('failed', '备份文件不存在或为空,回滚未开始。')
|
|
812
|
+
|
|
813
|
+
const moveStep = await runCommandStep(
|
|
814
|
+
account,
|
|
815
|
+
{ id: 'move-aside', title: 'Move the current directory aside', command: `mv ${shQuote(resource.appDir)} ${shQuote(previousDir)}` },
|
|
816
|
+
timeoutMs,
|
|
817
|
+
)
|
|
818
|
+
steps.push(moveStep)
|
|
819
|
+
if (moveStep.status !== 'success') {
|
|
820
|
+
const restoreStep = await runCommandStep(
|
|
821
|
+
account,
|
|
822
|
+
{
|
|
823
|
+
id: 'restore',
|
|
824
|
+
title: 'Restore the previous directory',
|
|
825
|
+
command: `if [ -d ${shQuote(previousDir)} ] && [ ! -e ${shQuote(resource.appDir)} ]; then mv ${shQuote(previousDir)} ${shQuote(resource.appDir)}; fi`,
|
|
826
|
+
},
|
|
827
|
+
timeoutMs,
|
|
828
|
+
)
|
|
829
|
+
steps.push(restoreStep)
|
|
830
|
+
return await finish('failed', '无法移动当前应用目录,已尝试恢复原目录。')
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
const extractStep = await runCommandStep(
|
|
834
|
+
account,
|
|
835
|
+
{
|
|
836
|
+
id: 'extract',
|
|
837
|
+
title: 'Extract the backup archive',
|
|
838
|
+
command: `tar -C ${shQuote(target.parent)} -xzf ${shQuote(target.backupPath)} && test -d ${shQuote(resource.appDir)}`,
|
|
839
|
+
},
|
|
840
|
+
timeoutMs,
|
|
841
|
+
)
|
|
842
|
+
steps.push(extractStep)
|
|
843
|
+
if (extractStep.status !== 'success') {
|
|
844
|
+
const restoreStep = await runCommandStep(
|
|
845
|
+
account,
|
|
846
|
+
{
|
|
847
|
+
id: 'restore',
|
|
848
|
+
title: 'Restore the previous directory',
|
|
849
|
+
command: `rm -rf ${shQuote(resource.appDir)} 2>/dev/null; mv ${shQuote(previousDir)} ${shQuote(resource.appDir)}`,
|
|
850
|
+
},
|
|
851
|
+
timeoutMs,
|
|
852
|
+
)
|
|
853
|
+
steps.push(restoreStep)
|
|
854
|
+
return await finish('failed', '解压备份失败,已尝试恢复原目录;备份文件保留。')
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (typeof resource.restartCommand === 'string' && resource.restartCommand !== '') {
|
|
858
|
+
const restartStep = await runCommandStep(
|
|
859
|
+
account,
|
|
860
|
+
{
|
|
861
|
+
id: 'restart',
|
|
862
|
+
title: 'Restart the service',
|
|
863
|
+
command: `cd ${shQuote(resource.appDir)} && bash -lc ${shQuote(resource.restartCommand)}`,
|
|
864
|
+
},
|
|
865
|
+
timeoutMs,
|
|
866
|
+
)
|
|
867
|
+
steps.push(restartStep)
|
|
868
|
+
if (restartStep.status !== 'success') return await finish('partial', '目录已回滚,但重启命令失败。')
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
if (typeof resource.verifyCommand === 'string' && resource.verifyCommand !== '') {
|
|
872
|
+
const verifyStep = await runCommandStep(
|
|
873
|
+
account,
|
|
874
|
+
{
|
|
875
|
+
id: 'verify',
|
|
876
|
+
title: 'Verify the deployment',
|
|
877
|
+
command: `cd ${shQuote(resource.appDir)} && bash -lc ${shQuote(resource.verifyCommand)}`,
|
|
878
|
+
},
|
|
879
|
+
timeoutMs,
|
|
880
|
+
)
|
|
881
|
+
steps.push(verifyStep)
|
|
882
|
+
if (verifyStep.status !== 'success') return await finish('partial', '目录已回滚,但校验命令失败。')
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
return await finish('success', `回滚完成;原目录已移动到 ${previousDir}。`)
|
|
886
|
+
} catch (error) {
|
|
887
|
+
steps.push({
|
|
888
|
+
id: 'internal',
|
|
889
|
+
title: 'Unexpected failure',
|
|
890
|
+
status: 'failed',
|
|
891
|
+
exitCode: null,
|
|
892
|
+
durationMs: 0,
|
|
893
|
+
stdoutTail: '',
|
|
894
|
+
stderrTail: errorMessage(error),
|
|
895
|
+
truncated: false,
|
|
896
|
+
})
|
|
897
|
+
return await finish('failed', '回滚流程异常终止。')
|
|
898
|
+
}
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
/**
|
|
902
|
+
* Execute a cached plan.
|
|
903
|
+
* @param {object} account - full account record.
|
|
904
|
+
* @param {string} planId - plan id from `plan`.
|
|
905
|
+
* @param {string | undefined} agentId - calling agent id.
|
|
906
|
+
* @returns {Promise<object>} stored history entry.
|
|
907
|
+
*/
|
|
908
|
+
async function apply(account, planId, agentId) {
|
|
909
|
+
prunePlans()
|
|
910
|
+
const cached = typeof planId === 'string' ? plans.get(planId) : undefined
|
|
911
|
+
if (cached === undefined) throw taggedError('plan-not-found', '更新计划不存在,请重新生成计划')
|
|
912
|
+
if (cached.agentId !== agentKey(agentId)) throw taggedError('plan-not-found', '更新计划不存在,请重新生成计划')
|
|
913
|
+
if (Date.parse(cached.expiresAt) <= now()) {
|
|
914
|
+
plans.delete(planId)
|
|
915
|
+
throw taggedError('plan-expired', '更新计划已过期,请重新生成计划')
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
const resource = resources.get(cached.resourceId)
|
|
919
|
+
if (!resource) throw invalid('资源不存在,请刷新资源目录')
|
|
920
|
+
if (resource.appDir !== cached.appDir || resource.serverId !== cached.serverId) {
|
|
921
|
+
throw invalid('资源的目标目录或服务器已变更,请重新生成计划')
|
|
922
|
+
}
|
|
923
|
+
if (account && typeof account.id === 'string' && account.id !== cached.serverId) {
|
|
924
|
+
throw invalid('账户与更新计划不匹配,请重新生成计划')
|
|
925
|
+
}
|
|
926
|
+
if (busyServers.has(cached.serverId)) throw taggedError('busy', '该服务器已有更新或回滚正在执行')
|
|
927
|
+
|
|
928
|
+
busyServers.add(cached.serverId)
|
|
929
|
+
try {
|
|
930
|
+
return await executeApply(account, cached, resource)
|
|
931
|
+
} finally {
|
|
932
|
+
busyServers.delete(cached.serverId)
|
|
933
|
+
plans.delete(planId)
|
|
934
|
+
}
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
/**
|
|
938
|
+
* Restore one backup archive.
|
|
939
|
+
*
|
|
940
|
+
* When an approval pin exists for this agent and resource, it decides which
|
|
941
|
+
* archive is restored, so the body can never drift from the prompt the
|
|
942
|
+
* operator approved. Without a pin the latest usable backup is resolved here,
|
|
943
|
+
* which is only safe for callers that do not pass through an approval prompt.
|
|
944
|
+
*
|
|
945
|
+
* @param {object} account - full account record.
|
|
946
|
+
* @param {string | object} resourceQuery - resource id/name query or view.
|
|
947
|
+
* @param {{ agentId?: string, historyId?: string }} [options] - rollback options.
|
|
948
|
+
* @returns {Promise<object>} stored history entry.
|
|
949
|
+
*/
|
|
950
|
+
async function rollback(account, resourceQuery, { agentId, historyId } = {}) {
|
|
951
|
+
const resource =
|
|
952
|
+
typeof resourceQuery === 'string'
|
|
953
|
+
? resources.find(resourceQuery)
|
|
954
|
+
: resourceQuery && typeof resourceQuery === 'object' && typeof resourceQuery.id === 'string'
|
|
955
|
+
? (resources.get(resourceQuery.id) ?? resourceQuery)
|
|
956
|
+
: undefined
|
|
957
|
+
if (!resource) throw invalid('资源不存在,请刷新资源目录')
|
|
958
|
+
|
|
959
|
+
const pin = peekRollback(agentId, resource.id)
|
|
960
|
+
let target
|
|
961
|
+
if (pin) {
|
|
962
|
+
// The pin is only valid while the resource still points at the approved
|
|
963
|
+
// directory and the archive is still the one that was recorded.
|
|
964
|
+
if (resource.appDir !== pin.appDir || resource.serverId !== pin.serverId) {
|
|
965
|
+
clearRollback(agentId, resource.id)
|
|
966
|
+
throw invalid('资源的目标目录或服务器已变更,请重新发起回滚')
|
|
967
|
+
}
|
|
968
|
+
const entry = history.get(pin.entryId)
|
|
969
|
+
if (!entry || entry.backupPath !== pin.backupPath) {
|
|
970
|
+
clearRollback(agentId, resource.id)
|
|
971
|
+
throw invalid('备份记录已变更,请重新发起回滚')
|
|
972
|
+
}
|
|
973
|
+
target = { resource, entry, backupPath: pin.backupPath, parent: pin.parent, backupDir: path.posix.dirname(pin.backupPath) }
|
|
974
|
+
} else {
|
|
975
|
+
target = resolveRollbackTarget(resource, historyId)
|
|
976
|
+
if (!target) throw taggedError('no-backup', '没有可用的备份记录,无法回滚')
|
|
977
|
+
}
|
|
978
|
+
if (account && typeof account.id === 'string' && account.id !== target.resource.serverId) {
|
|
979
|
+
throw invalid('账户与资源不匹配')
|
|
980
|
+
}
|
|
981
|
+
if (busyServers.has(target.resource.serverId)) throw taggedError('busy', '该服务器已有更新或回滚正在执行')
|
|
982
|
+
busyServers.add(target.resource.serverId)
|
|
983
|
+
try {
|
|
984
|
+
return await executeRollback(account, target)
|
|
985
|
+
} finally {
|
|
986
|
+
busyServers.delete(target.resource.serverId)
|
|
987
|
+
clearRollback(agentId, resource.id)
|
|
988
|
+
}
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
return { plan, peekPlan, apply, rollback, resolveRollbackTarget, pinRollback, peekRollback }
|
|
992
|
+
}
|