@kne/fastify-app-manager 0.1.9 → 0.1.11
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 +92 -21
- package/index.js +8 -0
- package/libs/controllers/app.js +105 -16
- package/libs/controllers/gateway.js +5 -2
- package/libs/models/appVersion.js +10 -0
- package/libs/services/app.js +324 -32
- package/libs/services/bootstrap.js +68 -5
- package/libs/services/dbops.js +2 -10
- package/libs/services/migration.js +34 -46
- package/libs/utils/dbIdentity.js +16 -7
- package/libs/utils/deployProgress.js +37 -0
- package/libs/utils/env.js +30 -11
- package/libs/utils/pathRewrite.js +28 -3
- package/libs/utils/version.js +7 -2
- package/libs/utils/versionSql.js +33 -0
- package/package.json +1 -1
package/libs/services/app.js
CHANGED
|
@@ -10,7 +10,8 @@ const { ensureLogFiles, readLogTail, readLastLines, appendLog, countLines } = re
|
|
|
10
10
|
const { currentFileName, isCurrentFileName, resolveLogFile, listLogFiles } = require('../utils/logRotate');
|
|
11
11
|
const { toIsoInTz, convertLogText } = require('../utils/logTime');
|
|
12
12
|
const { runSqlMigrations, MIGRATION_ACTIONS } = require('../utils/migrate');
|
|
13
|
-
const {
|
|
13
|
+
const { readSqlDir, writeSqlDir } = require('../utils/versionSql');
|
|
14
|
+
const { createDeployProgress } = require('../utils/deployProgress');
|
|
14
15
|
const pm2Util = require('../utils/pm2');
|
|
15
16
|
|
|
16
17
|
const NAME_RE = /^[a-z0-9]([a-z0-9-]{0,62}[a-z0-9])?$/;
|
|
@@ -45,6 +46,27 @@ module.exports = fp(async (fastify, options) => {
|
|
|
45
46
|
}
|
|
46
47
|
return category.code ? { code: category.code, name: category.name || category.code } : null;
|
|
47
48
|
};
|
|
49
|
+
// 应用入口:label + 相对应用根的 path;去空、补前导 /、按 path 去重
|
|
50
|
+
const normalizeEntries = entries => {
|
|
51
|
+
const seen = new Set();
|
|
52
|
+
return (Array.isArray(entries) ? entries : []).reduce((result, item) => {
|
|
53
|
+
const label = String(item?.label || '').trim();
|
|
54
|
+
const rawPath = String(item?.path || '').trim();
|
|
55
|
+
if (!label || !rawPath) {
|
|
56
|
+
return result;
|
|
57
|
+
}
|
|
58
|
+
const entryPath = rawPath.startsWith('/') ? rawPath : `/${rawPath}`;
|
|
59
|
+
if (!seen.has(entryPath)) {
|
|
60
|
+
seen.add(entryPath);
|
|
61
|
+
result.push({ label, path: entryPath });
|
|
62
|
+
}
|
|
63
|
+
return result;
|
|
64
|
+
}, []);
|
|
65
|
+
};
|
|
66
|
+
const resolveEntries = appLike => {
|
|
67
|
+
const base = mountPrefix(appLike.name);
|
|
68
|
+
return normalizeEntries(appLike.options?.entries).map(entry => Object.assign({}, entry, { url: `${base}${entry.path}` }));
|
|
69
|
+
};
|
|
48
70
|
|
|
49
71
|
const toPublicApp = app => {
|
|
50
72
|
if (!app) {
|
|
@@ -59,6 +81,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
59
81
|
json.pathUrl = `${mountPrefix(json.name)}/`;
|
|
60
82
|
json.category = json.options?.category || null;
|
|
61
83
|
json.isPublic = isAppPublic(json);
|
|
84
|
+
json.entries = resolveEntries(json);
|
|
62
85
|
return json;
|
|
63
86
|
};
|
|
64
87
|
|
|
@@ -71,7 +94,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
71
94
|
};
|
|
72
95
|
|
|
73
96
|
const create = async data => {
|
|
74
|
-
const { name, label, domain, icon, description, category, isPublic, env, pm2Config, options: appOptions } = data;
|
|
97
|
+
const { name, label, domain, icon, description, category, isPublic, entries, env, pm2Config, options: appOptions } = data;
|
|
75
98
|
if (!name || !NAME_RE.test(name)) {
|
|
76
99
|
httpError(400, 'invalid name: use lowercase slug [a-z0-9-]');
|
|
77
100
|
}
|
|
@@ -111,6 +134,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
111
134
|
if (isPublic !== undefined) {
|
|
112
135
|
normalizedOptions.isPublic = !!isPublic;
|
|
113
136
|
}
|
|
137
|
+
if (entries !== undefined) {
|
|
138
|
+
normalizedOptions.entries = normalizeEntries(entries);
|
|
139
|
+
}
|
|
114
140
|
|
|
115
141
|
const app = await models.app.create({
|
|
116
142
|
name,
|
|
@@ -118,7 +144,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
118
144
|
domain: domain || null,
|
|
119
145
|
icon: icon || null,
|
|
120
146
|
description: description || null,
|
|
121
|
-
env: omitSystemEnv(env),
|
|
147
|
+
env: omitSystemEnv(env, options.systemEnvKeys),
|
|
122
148
|
pm2Config: pm2Config || {},
|
|
123
149
|
options: normalizedOptions,
|
|
124
150
|
port,
|
|
@@ -130,7 +156,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
130
156
|
return toPublicApp(app);
|
|
131
157
|
};
|
|
132
158
|
|
|
133
|
-
const save = async ({ name, category, isPublic, ...data }) => {
|
|
159
|
+
const save = async ({ name, category, isPublic, entries, ...data }) => {
|
|
134
160
|
const app = await getByName(name);
|
|
135
161
|
const omit = ['name', 'port', 'rootPath', 'pm2Name', 'status', 'currentVersionId'];
|
|
136
162
|
const patch = {};
|
|
@@ -161,6 +187,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
161
187
|
if (isPublic !== undefined) {
|
|
162
188
|
patch.options = Object.assign({}, patch.options || app.options || {}, { isPublic: !!isPublic });
|
|
163
189
|
}
|
|
190
|
+
if (entries !== undefined) {
|
|
191
|
+
patch.options = Object.assign({}, patch.options || app.options || {}, { entries: normalizeEntries(entries) });
|
|
192
|
+
}
|
|
164
193
|
await app.update(patch);
|
|
165
194
|
return toPublicApp(app);
|
|
166
195
|
};
|
|
@@ -170,7 +199,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
170
199
|
// 密钥类型不可撤销,只随变量删除而移除
|
|
171
200
|
const secretEnvKeys = normalizeSecretKeys([...resolveSecretEnvKeys(app), ...(nextSecretKeys || [])]);
|
|
172
201
|
const nextEnv = applyEnvPatch(app.env || {}, env || {}, Object.assign({}, options, { secretEnvKeys }));
|
|
173
|
-
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
202
|
+
const cleanedSecrets = secretEnvKeys.filter(key => !isSystemEnvKey(key, options.systemEnvKeys) && Object.prototype.hasOwnProperty.call(nextEnv, key));
|
|
174
203
|
await app.update({
|
|
175
204
|
env: nextEnv,
|
|
176
205
|
options: Object.assign({}, app.options || {}, { secretEnvKeys: cleanedSecrets })
|
|
@@ -211,7 +240,8 @@ module.exports = fp(async (fastify, options) => {
|
|
|
211
240
|
description: app.description,
|
|
212
241
|
category: app.options?.category || null,
|
|
213
242
|
isPublic: isAppPublic(app),
|
|
214
|
-
pathUrl: `${mountPrefix(app.name)}
|
|
243
|
+
pathUrl: `${mountPrefix(app.name)}/`,
|
|
244
|
+
entries: resolveEntries(app)
|
|
215
245
|
}));
|
|
216
246
|
return { pageData, totalCount: pageData.length };
|
|
217
247
|
};
|
|
@@ -221,7 +251,42 @@ module.exports = fp(async (fastify, options) => {
|
|
|
221
251
|
return toPublicApp(app);
|
|
222
252
|
};
|
|
223
253
|
|
|
224
|
-
const
|
|
254
|
+
const versionSqlDir = ver => path.join(ver.artifactPath, 'server', ver.migrationPath || options.sqlPath || 'sql');
|
|
255
|
+
|
|
256
|
+
const hasArtifact = ver => fs.pathExists(path.join(ver.artifactPath, 'server'));
|
|
257
|
+
|
|
258
|
+
/** SQL 以数据库为准;旧版本行(migrationFiles 为空)首次访问时从版本目录回填 */
|
|
259
|
+
const getMigrationFiles = async ver => {
|
|
260
|
+
if (Array.isArray(ver.migrationFiles)) {
|
|
261
|
+
return ver.migrationFiles;
|
|
262
|
+
}
|
|
263
|
+
if (!(await hasArtifact(ver))) {
|
|
264
|
+
return [];
|
|
265
|
+
}
|
|
266
|
+
const files = await readSqlDir(versionSqlDir(ver));
|
|
267
|
+
await ver.update({ migrationFiles: files, hasMigration: files.length > 0 });
|
|
268
|
+
return files;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
/** 写库,版本目录在磁盘上时同步覆盖其 .sql */
|
|
272
|
+
const saveMigrationFiles = async (ver, files) => {
|
|
273
|
+
const sorted = [...files].sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
274
|
+
await ver.update({ migrationFiles: sorted, hasMigration: sorted.length > 0 });
|
|
275
|
+
if (await hasArtifact(ver)) {
|
|
276
|
+
await writeSqlDir(versionSqlDir(ver), sorted);
|
|
277
|
+
}
|
|
278
|
+
return sorted;
|
|
279
|
+
};
|
|
280
|
+
|
|
281
|
+
const toPublicVersion = (row, files = row.migrationFiles) => {
|
|
282
|
+
const { migrationFiles, ...json } = row.toJSON();
|
|
283
|
+
if (Array.isArray(files) && files.length) {
|
|
284
|
+
json.sqlFiles = files.map(item => item.name);
|
|
285
|
+
}
|
|
286
|
+
return json;
|
|
287
|
+
};
|
|
288
|
+
|
|
289
|
+
const uploadVersion = async ({ name, version, label, zipBuffer, fileId }) => {
|
|
225
290
|
if (!version) {
|
|
226
291
|
httpError(400, 'version is required');
|
|
227
292
|
}
|
|
@@ -241,7 +306,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
241
306
|
// Prepare outside the version table so failed or in-progress uploads never appear in the version list.
|
|
242
307
|
const versionsDir = path.join(app.rootPath, 'versions');
|
|
243
308
|
const stagingPath = path.join(versionsDir, `.staging-${crypto.randomUUID()}`);
|
|
309
|
+
// 有宿主文件的版本只校验不常驻磁盘,部署时再解压;没有的只能保留目录
|
|
310
|
+
const keepArtifact = !fileId;
|
|
244
311
|
let prepared;
|
|
312
|
+
let migrationFiles;
|
|
245
313
|
try {
|
|
246
314
|
prepared = await prepareVersionArtifact({
|
|
247
315
|
zipBuffer,
|
|
@@ -249,8 +317,10 @@ module.exports = fp(async (fastify, options) => {
|
|
|
249
317
|
maxZipSize: options.maxZipSize,
|
|
250
318
|
maxZipEntries: options.maxZipEntries,
|
|
251
319
|
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
252
|
-
sqlPath: options.sqlPath
|
|
320
|
+
sqlPath: options.sqlPath,
|
|
321
|
+
install: keepArtifact
|
|
253
322
|
});
|
|
323
|
+
migrationFiles = await readSqlDir(path.join(stagingPath, 'server', prepared.migrationPath));
|
|
254
324
|
} catch (e) {
|
|
255
325
|
await fs.remove(stagingPath).catch(() => {});
|
|
256
326
|
httpError(400, e.message);
|
|
@@ -258,26 +328,34 @@ module.exports = fp(async (fastify, options) => {
|
|
|
258
328
|
|
|
259
329
|
let artifactPath = null;
|
|
260
330
|
try {
|
|
261
|
-
|
|
331
|
+
const result = await models.appVersion.sequelize.transaction(async transaction => {
|
|
262
332
|
const versionRow = await models.appVersion.create(
|
|
263
333
|
{
|
|
264
334
|
appName: name,
|
|
265
335
|
version,
|
|
266
336
|
label: label || null,
|
|
267
337
|
artifactPath: stagingPath,
|
|
268
|
-
hasMigration:
|
|
269
|
-
migrationPath: prepared.migrationPath
|
|
338
|
+
hasMigration: migrationFiles.length > 0,
|
|
339
|
+
migrationPath: prepared.migrationPath,
|
|
340
|
+
fileId: fileId ? String(fileId) : null,
|
|
341
|
+
migrationFiles
|
|
270
342
|
},
|
|
271
343
|
{ transaction }
|
|
272
344
|
);
|
|
273
345
|
artifactPath = path.join(versionsDir, String(versionRow.id));
|
|
274
|
-
|
|
346
|
+
if (keepArtifact) {
|
|
347
|
+
await fs.move(stagingPath, artifactPath);
|
|
348
|
+
}
|
|
275
349
|
await versionRow.update({ artifactPath }, { transaction });
|
|
276
|
-
return versionRow
|
|
350
|
+
return toPublicVersion(versionRow);
|
|
277
351
|
});
|
|
352
|
+
if (!keepArtifact) {
|
|
353
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
354
|
+
}
|
|
355
|
+
return result;
|
|
278
356
|
} catch (e) {
|
|
279
357
|
await fs.remove(stagingPath).catch(() => {});
|
|
280
|
-
if (artifactPath) {
|
|
358
|
+
if (artifactPath && keepArtifact) {
|
|
281
359
|
await fs.remove(artifactPath).catch(() => {});
|
|
282
360
|
}
|
|
283
361
|
if (e.name === 'SequelizeUniqueConstraintError') {
|
|
@@ -297,16 +375,52 @@ module.exports = fp(async (fastify, options) => {
|
|
|
297
375
|
});
|
|
298
376
|
const pageData = await Promise.all(
|
|
299
377
|
rows.map(async row => {
|
|
300
|
-
const
|
|
301
|
-
|
|
302
|
-
json.sqlFiles = await listSqlFiles(path.join(json.artifactPath, 'server', json.migrationPath || 'sql'));
|
|
303
|
-
}
|
|
304
|
-
return json;
|
|
378
|
+
const files = await getMigrationFiles(row);
|
|
379
|
+
return toPublicVersion(row, files);
|
|
305
380
|
})
|
|
306
381
|
);
|
|
307
382
|
return { pageData, totalCount: count };
|
|
308
383
|
};
|
|
309
384
|
|
|
385
|
+
const listArtifactFiles = async (root, dir = '') => {
|
|
386
|
+
const files = [];
|
|
387
|
+
for (const entry of await fs.readdir(path.join(root, dir), { withFileTypes: true })) {
|
|
388
|
+
const rel = dir ? `${dir}/${entry.name}` : entry.name;
|
|
389
|
+
// 部署时装的依赖与复制进 server 的 build 都不属于原始包
|
|
390
|
+
if (rel === 'server/node_modules' || rel === 'server/build') {
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (entry.isDirectory()) {
|
|
394
|
+
files.push(...(await listArtifactFiles(root, rel)));
|
|
395
|
+
} else if (entry.isFile()) {
|
|
396
|
+
files.push(rel);
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
return files;
|
|
400
|
+
};
|
|
401
|
+
|
|
402
|
+
/** 有 fileId 时返回原始 zip;否则从版本目录还原包内容(不含 server/node_modules、server/build) */
|
|
403
|
+
const resolveVersionPackage = async ({ name, versionId }) => {
|
|
404
|
+
await getByName(name);
|
|
405
|
+
const ver = await models.appVersion.findByPk(versionId);
|
|
406
|
+
if (!ver || ver.appName !== name) {
|
|
407
|
+
httpError(404, 'version not found');
|
|
408
|
+
}
|
|
409
|
+
const fileName = `${name}-${ver.version}.zip`;
|
|
410
|
+
if (ver.fileId && typeof options.resolveVersionFile === 'function') {
|
|
411
|
+
try {
|
|
412
|
+
const buffer = await options.resolveVersionFile({ fileId: ver.fileId, name, version: ver.version });
|
|
413
|
+
return { fileName, buffer };
|
|
414
|
+
} catch (e) {
|
|
415
|
+
fastify.log.warn({ err: e, app: name, version: ver.version }, 'resolve version file failed, fallback to artifact');
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
if (!(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
419
|
+
httpError(404, 'version package not found');
|
|
420
|
+
}
|
|
421
|
+
return { fileName, artifactPath: ver.artifactPath, files: await listArtifactFiles(ver.artifactPath) };
|
|
422
|
+
};
|
|
423
|
+
|
|
310
424
|
const healthCheck = async (port, { timeoutMs, intervalMs, healthPath }) => {
|
|
311
425
|
const started = Date.now();
|
|
312
426
|
const url = `http://127.0.0.1:${port}${healthPath || '/'}`;
|
|
@@ -336,6 +450,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
336
450
|
|
|
337
451
|
/** 同一 app 并发同步合并为一次,避免 restart/reconcile 竞态 */
|
|
338
452
|
const statusSyncInflight = new Map();
|
|
453
|
+
const deployProgress = createDeployProgress();
|
|
339
454
|
|
|
340
455
|
/**
|
|
341
456
|
* 根据 PM2 真实进程状态 + HTTP 健康检查,异步写回 DB status。
|
|
@@ -430,11 +545,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
430
545
|
|
|
431
546
|
const finishDeployAsync = async appId => syncRealStatus(appId, { recoverIfMissing: false });
|
|
432
547
|
|
|
433
|
-
const scheduleStatusSync = (appId, opts) => {
|
|
548
|
+
const scheduleStatusSync = (appId, { onSettled, ...opts } = {}) => {
|
|
434
549
|
setImmediate(() => {
|
|
435
|
-
syncRealStatus(appId, opts)
|
|
436
|
-
|
|
437
|
-
|
|
550
|
+
syncRealStatus(appId, opts)
|
|
551
|
+
.catch(err => {
|
|
552
|
+
fastify.log.warn({ err, appId }, 'syncRealStatus failed');
|
|
553
|
+
return { status: 'error', message: err.message };
|
|
554
|
+
})
|
|
555
|
+
.then(result => onSettled && onSettled(result));
|
|
438
556
|
});
|
|
439
557
|
};
|
|
440
558
|
|
|
@@ -443,14 +561,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
443
561
|
return syncRealStatus(app.id, { recoverIfMissing });
|
|
444
562
|
};
|
|
445
563
|
|
|
446
|
-
const syncAllStatuses = async ({ recoverIfMissing = true } = {}) => {
|
|
564
|
+
const syncAllStatuses = async ({ recoverIfMissing = true, excludeIds = [] } = {}) => {
|
|
447
565
|
const apps = await models.app.findAll({
|
|
448
566
|
where: {
|
|
449
567
|
status: ['running', 'deploying', 'stopped', 'error']
|
|
450
568
|
}
|
|
451
569
|
});
|
|
570
|
+
const excluded = new Set(excludeIds.map(String));
|
|
452
571
|
const results = [];
|
|
453
572
|
for (const app of apps) {
|
|
573
|
+
if (excluded.has(String(app.id))) {
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
454
576
|
const shouldRecover = recoverIfMissing && (app.status === 'running' || app.status === 'deploying');
|
|
455
577
|
results.push(await syncRealStatus(app.id, { recoverIfMissing: shouldRecover }));
|
|
456
578
|
}
|
|
@@ -480,6 +602,26 @@ module.exports = fp(async (fastify, options) => {
|
|
|
480
602
|
await app.reload();
|
|
481
603
|
}
|
|
482
604
|
|
|
605
|
+
if (typeof options.resolveSystemEnv === 'function') {
|
|
606
|
+
const systemEnv = await options.resolveSystemEnv({ app, version, serverDir });
|
|
607
|
+
if (systemEnv && typeof systemEnv === 'object') {
|
|
608
|
+
const persisted = {};
|
|
609
|
+
for (const [key, value] of Object.entries(systemEnv)) {
|
|
610
|
+
if (value == null) {
|
|
611
|
+
continue;
|
|
612
|
+
}
|
|
613
|
+
env[key] = String(value);
|
|
614
|
+
if ((options.systemEnvKeys || []).includes(key) && app.env?.[key] !== env[key]) {
|
|
615
|
+
persisted[key] = env[key];
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (Object.keys(persisted).length) {
|
|
619
|
+
await app.update({ env: Object.assign({}, app.env || {}, persisted) });
|
|
620
|
+
await app.reload();
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
483
625
|
const pathBase = mountPrefix(app.name);
|
|
484
626
|
// Domain-first: inject `/` so custom Host SPA works; path mode then relies on gateway rewrite fallback.
|
|
485
627
|
// Path-only: inject `/app/{name}` so runtime* matches strip-prefix gateway.
|
|
@@ -502,7 +644,117 @@ module.exports = fp(async (fastify, options) => {
|
|
|
502
644
|
});
|
|
503
645
|
};
|
|
504
646
|
|
|
505
|
-
const
|
|
647
|
+
const linkCurrent = async (app, artifactPath) => {
|
|
648
|
+
const currentLink = path.join(app.rootPath, 'current');
|
|
649
|
+
await fs.remove(currentLink).catch(() => {});
|
|
650
|
+
await fs.symlink(artifactPath, currentLink).catch(async () => {
|
|
651
|
+
// Windows fallback: copy marker file
|
|
652
|
+
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), artifactPath);
|
|
653
|
+
});
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
/** 只做 fs 检查:返回当前版本目录(artifactPath/server)缺失的应用 */
|
|
657
|
+
const listMissingArtifacts = async () => {
|
|
658
|
+
const { Op } = fastify.sequelize.Sequelize;
|
|
659
|
+
const apps = await models.app.findAll({ where: { currentVersionId: { [Op.ne]: null } } });
|
|
660
|
+
const missing = [];
|
|
661
|
+
for (const app of apps) {
|
|
662
|
+
const ver = await models.appVersion.findByPk(app.currentVersionId);
|
|
663
|
+
if (ver && !(await fs.pathExists(path.join(ver.artifactPath, 'server')))) {
|
|
664
|
+
missing.push({ app, ver });
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return missing;
|
|
668
|
+
};
|
|
669
|
+
|
|
670
|
+
const canResolveFile = ver => !!ver.fileId && typeof options.resolveVersionFile === 'function';
|
|
671
|
+
|
|
672
|
+
/** 版本目录不在磁盘时按 fileId 取回 zip 解压并装依赖;再以数据库中的 SQL 覆盖目录内 .sql */
|
|
673
|
+
const materializeArtifact = async (app, ver, { onProgress } = {}) => {
|
|
674
|
+
if (await hasArtifact(ver)) {
|
|
675
|
+
onProgress && onProgress('reuse');
|
|
676
|
+
} else {
|
|
677
|
+
if (!canResolveFile(ver)) {
|
|
678
|
+
throw new Error(`version ${ver.version} artifact missing and cannot be restored without fileId`);
|
|
679
|
+
}
|
|
680
|
+
const stagingPath = path.join(app.rootPath, 'versions', `.staging-${crypto.randomUUID()}`);
|
|
681
|
+
try {
|
|
682
|
+
onProgress && onProgress('download');
|
|
683
|
+
const zipBuffer = await options.resolveVersionFile({ fileId: ver.fileId, name: app.name, version: ver.version });
|
|
684
|
+
await prepareVersionArtifact({
|
|
685
|
+
zipBuffer,
|
|
686
|
+
artifactPath: stagingPath,
|
|
687
|
+
maxZipSize: options.maxZipSize,
|
|
688
|
+
maxZipEntries: options.maxZipEntries,
|
|
689
|
+
npmInstallTimeoutMs: options.npmInstallTimeoutMs,
|
|
690
|
+
sqlPath: options.sqlPath,
|
|
691
|
+
onProgress
|
|
692
|
+
});
|
|
693
|
+
await fs.move(stagingPath, ver.artifactPath, { overwrite: true });
|
|
694
|
+
} catch (e) {
|
|
695
|
+
await fs.remove(stagingPath).catch(() => {});
|
|
696
|
+
throw e;
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
await writeSqlDir(versionSqlDir(ver), await getMigrationFiles(ver));
|
|
700
|
+
};
|
|
701
|
+
|
|
702
|
+
/** 删除非当前版本中可从宿主文件重建的目录;删前先把 SQL 回填进库 */
|
|
703
|
+
const pruneVersionArtifacts = async app => {
|
|
704
|
+
const versions = await models.appVersion.findAll({ where: { appName: app.name } });
|
|
705
|
+
const removed = [];
|
|
706
|
+
for (const ver of versions) {
|
|
707
|
+
if (String(ver.id) === String(app.currentVersionId) || !canResolveFile(ver) || !(await fs.pathExists(ver.artifactPath))) {
|
|
708
|
+
continue;
|
|
709
|
+
}
|
|
710
|
+
await getMigrationFiles(ver);
|
|
711
|
+
await fs.remove(ver.artifactPath);
|
|
712
|
+
removed.push(ver.version);
|
|
713
|
+
}
|
|
714
|
+
if (removed.length) {
|
|
715
|
+
fastify.log.info({ app: app.name, versions: removed }, 'pruned non-current version artifacts');
|
|
716
|
+
}
|
|
717
|
+
return removed;
|
|
718
|
+
};
|
|
719
|
+
|
|
720
|
+
const pruneAllVersionArtifacts = async () => {
|
|
721
|
+
const results = [];
|
|
722
|
+
for (const app of await models.app.findAll()) {
|
|
723
|
+
try {
|
|
724
|
+
results.push({ name: app.name, removed: await pruneVersionArtifacts(app) });
|
|
725
|
+
} catch (e) {
|
|
726
|
+
fastify.log.warn({ err: e, app: app.name }, 'prune version artifacts failed');
|
|
727
|
+
}
|
|
728
|
+
}
|
|
729
|
+
return results;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
/** 补回当前版本目录并恢复 current 链接;不跑 SQL 迁移 */
|
|
733
|
+
const restoreArtifact = async ({ app, ver }) => {
|
|
734
|
+
if (!canResolveFile(ver)) {
|
|
735
|
+
fastify.log.warn({ app: app.name, version: ver.version }, 'current version artifact missing and cannot be restored without fileId');
|
|
736
|
+
return { name: app.name, version: ver.version, action: 'skipped', reason: 'no fileId' };
|
|
737
|
+
}
|
|
738
|
+
try {
|
|
739
|
+
await materializeArtifact(app, ver);
|
|
740
|
+
await linkCurrent(app, ver.artifactPath);
|
|
741
|
+
fastify.log.info({ app: app.name, version: ver.version }, 'current version artifact restored from file');
|
|
742
|
+
return { name: app.name, version: ver.version, action: 'restored' };
|
|
743
|
+
} catch (e) {
|
|
744
|
+
fastify.log.warn({ err: e, app: app.name, version: ver.version }, 'restore current version artifact failed');
|
|
745
|
+
return { name: app.name, version: ver.version, action: 'failed', reason: e.message };
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
|
|
749
|
+
const restoreMissingArtifacts = async () => {
|
|
750
|
+
const results = [];
|
|
751
|
+
for (const item of await listMissingArtifacts()) {
|
|
752
|
+
results.push(await restoreArtifact(item));
|
|
753
|
+
}
|
|
754
|
+
return results;
|
|
755
|
+
};
|
|
756
|
+
|
|
757
|
+
const deploy = async ({ name, versionId, version, runMigration = true, migrations, progressId }) => {
|
|
506
758
|
const app = await getByName(name);
|
|
507
759
|
let ver = null;
|
|
508
760
|
if (versionId) {
|
|
@@ -514,21 +766,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
514
766
|
httpError(404, 'version not found');
|
|
515
767
|
}
|
|
516
768
|
|
|
769
|
+
const progress = deployProgress.begin(app.name, progressId || crypto.randomUUID());
|
|
770
|
+
let step = 'package';
|
|
771
|
+
progress.report(step, 'running');
|
|
772
|
+
try {
|
|
773
|
+
await materializeArtifact(app, ver, { onProgress: detail => progress.report('package', 'running', { detail }) });
|
|
774
|
+
} catch (e) {
|
|
775
|
+
progress.report(step, 'error', { message: e.message });
|
|
776
|
+
httpError(400, e.message);
|
|
777
|
+
}
|
|
778
|
+
progress.report(step, 'done');
|
|
779
|
+
|
|
517
780
|
await app.update({
|
|
518
781
|
status: 'deploying',
|
|
519
782
|
currentVersionId: String(ver.id),
|
|
520
783
|
message: null
|
|
521
784
|
});
|
|
522
785
|
|
|
523
|
-
|
|
524
|
-
await fs.remove(currentLink).catch(() => {});
|
|
525
|
-
await fs.symlink(ver.artifactPath, currentLink).catch(async () => {
|
|
526
|
-
// Windows fallback: copy marker file
|
|
527
|
-
await fs.writeFile(path.join(app.rootPath, 'current-path.txt'), ver.artifactPath);
|
|
528
|
-
});
|
|
786
|
+
await linkCurrent(app, ver.artifactPath);
|
|
529
787
|
|
|
530
788
|
try {
|
|
789
|
+
step = 'migrate';
|
|
531
790
|
if (runMigration && options.migrateBeforeStart && ver.hasMigration) {
|
|
791
|
+
progress.report(step, 'running');
|
|
532
792
|
const env = mergeEnv({
|
|
533
793
|
passthroughEnvKeys: options.passthroughEnvKeys,
|
|
534
794
|
appEnv: app.env,
|
|
@@ -563,6 +823,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
563
823
|
} catch (e) {
|
|
564
824
|
// ignore ownership merge failures
|
|
565
825
|
}
|
|
826
|
+
progress.report(step, 'done');
|
|
827
|
+
} else {
|
|
828
|
+
progress.report(step, 'skipped');
|
|
566
829
|
}
|
|
567
830
|
|
|
568
831
|
if (runMigration === false) {
|
|
@@ -570,18 +833,29 @@ module.exports = fp(async (fastify, options) => {
|
|
|
570
833
|
app.env = Object.assign({}, app.env, { RUN_SQL_ON_SYNC: 'false' });
|
|
571
834
|
}
|
|
572
835
|
|
|
836
|
+
step = 'start';
|
|
837
|
+
progress.report(step, 'running');
|
|
573
838
|
const beforeForReady = await snapshotTablesSafe(app);
|
|
574
839
|
await startProcess(app);
|
|
575
|
-
|
|
840
|
+
progress.report(step, 'done');
|
|
841
|
+
step = 'ready';
|
|
842
|
+
progress.report(step, 'running');
|
|
843
|
+
scheduleStatusSync(app.id, {
|
|
844
|
+
beforeTables: beforeForReady,
|
|
845
|
+
onSettled: result => (result?.status === 'running' ? progress.report('ready', 'done') : progress.report('ready', 'error', { message: result?.message || result?.status || null }))
|
|
846
|
+
});
|
|
847
|
+
await pruneVersionArtifacts(app).catch(err => fastify.log.warn({ err, app: app.name }, 'prune version artifacts failed'));
|
|
576
848
|
return {
|
|
577
849
|
name: app.name,
|
|
578
850
|
status: 'deploying',
|
|
579
851
|
versionId: String(ver.id),
|
|
852
|
+
progressId: progress.progressId,
|
|
580
853
|
port: app.port,
|
|
581
854
|
pathUrl: `${mountPrefix(app.name)}/`,
|
|
582
855
|
domain: app.domain
|
|
583
856
|
};
|
|
584
857
|
} catch (e) {
|
|
858
|
+
progress.report(step, 'error', { message: e.message });
|
|
585
859
|
await app.update({ status: 'error', message: e.message });
|
|
586
860
|
httpError(400, e.message);
|
|
587
861
|
}
|
|
@@ -663,9 +937,18 @@ module.exports = fp(async (fastify, options) => {
|
|
|
663
937
|
}
|
|
664
938
|
}
|
|
665
939
|
|
|
940
|
+
const snapshot = app.toJSON();
|
|
666
941
|
await models.appVersion.destroy({ where: { appName: name } });
|
|
667
942
|
await fs.remove(app.rootPath).catch(() => {});
|
|
943
|
+
deployProgress.clear(app.name);
|
|
668
944
|
await app.destroy();
|
|
945
|
+
if (typeof options.onAppRemoved === 'function') {
|
|
946
|
+
try {
|
|
947
|
+
await options.onAppRemoved({ app: snapshot });
|
|
948
|
+
} catch (e) {
|
|
949
|
+
fastify.log.warn({ err: e, appName: name }, 'onAppRemoved hook failed');
|
|
950
|
+
}
|
|
951
|
+
}
|
|
669
952
|
return {
|
|
670
953
|
removed: true,
|
|
671
954
|
export: exportResult,
|
|
@@ -801,6 +1084,14 @@ module.exports = fp(async (fastify, options) => {
|
|
|
801
1084
|
syncStatus,
|
|
802
1085
|
syncAllStatuses,
|
|
803
1086
|
remove,
|
|
1087
|
+
resolveVersionPackage,
|
|
1088
|
+
listMissingArtifacts,
|
|
1089
|
+
restoreArtifact,
|
|
1090
|
+
restoreMissingArtifacts,
|
|
1091
|
+
pruneVersionArtifacts,
|
|
1092
|
+
pruneAllVersionArtifacts,
|
|
1093
|
+
getMigrationFiles,
|
|
1094
|
+
saveMigrationFiles,
|
|
804
1095
|
logs,
|
|
805
1096
|
logFiles,
|
|
806
1097
|
resolveAppLogFile,
|
|
@@ -808,6 +1099,7 @@ module.exports = fp(async (fastify, options) => {
|
|
|
808
1099
|
removeLogFiles,
|
|
809
1100
|
load,
|
|
810
1101
|
getLoadStore,
|
|
1102
|
+
getDeployProgress: () => deployProgress,
|
|
811
1103
|
toPublicApp,
|
|
812
1104
|
mountPrefix,
|
|
813
1105
|
findByHost,
|
|
@@ -35,8 +35,28 @@ module.exports = fp(async (fastify, options) => {
|
|
|
35
35
|
return models.app.findOne({ where: { pm2Name } });
|
|
36
36
|
};
|
|
37
37
|
|
|
38
|
+
/** 宿主 sync() 不会给已有表加列:老库升级时补齐后续新增的可空列 */
|
|
39
|
+
const ensureSchema = async () => {
|
|
40
|
+
const { models } = fastify[options.name];
|
|
41
|
+
const model = models.appVersion;
|
|
42
|
+
const queryInterface = model.sequelize.getQueryInterface();
|
|
43
|
+
const table = model.getTableName();
|
|
44
|
+
const columns = await queryInterface.describeTable(table);
|
|
45
|
+
for (const name of ['fileId', 'migrationFiles']) {
|
|
46
|
+
const attr = model.rawAttributes[name];
|
|
47
|
+
if (!columns[attr.field]) {
|
|
48
|
+
await queryInterface.addColumn(table, attr.field, { type: attr.type, allowNull: true, comment: attr.comment });
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
38
53
|
const onReady = async () => {
|
|
39
54
|
await fsEnsureAppsRoot();
|
|
55
|
+
try {
|
|
56
|
+
await ensureSchema();
|
|
57
|
+
} catch (e) {
|
|
58
|
+
fastify.log.warn({ err: e }, 'ensure app-manager schema failed');
|
|
59
|
+
}
|
|
40
60
|
try {
|
|
41
61
|
await pm2Util.connect();
|
|
42
62
|
connected = true;
|
|
@@ -87,9 +107,9 @@ module.exports = fp(async (fastify, options) => {
|
|
|
87
107
|
loadTimer.unref?.();
|
|
88
108
|
}
|
|
89
109
|
|
|
90
|
-
// 父应用重启后不阻塞 ready:后台按 PM2
|
|
110
|
+
// 父应用重启后不阻塞 ready:后台按 PM2 真实状态对齐 DB,缺部署目录的应用另行补回后再拉起
|
|
91
111
|
setImmediate(() => {
|
|
92
|
-
|
|
112
|
+
recoverOnStartup().catch(err => {
|
|
93
113
|
fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
|
|
94
114
|
});
|
|
95
115
|
rotateLogs().catch(err => fastify.log.warn({ err }, 'log rotation failed'));
|
|
@@ -103,14 +123,57 @@ module.exports = fp(async (fastify, options) => {
|
|
|
103
123
|
await fs.ensureDir(options.appsRoot);
|
|
104
124
|
};
|
|
105
125
|
|
|
106
|
-
const reconcile = async () => {
|
|
126
|
+
const reconcile = async ({ excludeIds } = {}) => {
|
|
107
127
|
const { services } = fastify[options.name];
|
|
108
128
|
if (typeof services.app.syncAllStatuses === 'function') {
|
|
109
|
-
return services.app.syncAllStatuses({ recoverIfMissing: true });
|
|
129
|
+
return services.app.syncAllStatuses({ recoverIfMissing: true, excludeIds });
|
|
110
130
|
}
|
|
111
131
|
return [];
|
|
112
132
|
};
|
|
113
133
|
|
|
134
|
+
/** 逐个补回缺失的部署目录(npm install 耗时),每补完一个就单独对齐状态并按需拉起 */
|
|
135
|
+
const restoreArtifacts = async missing => {
|
|
136
|
+
const { services } = fastify[options.name];
|
|
137
|
+
const results = [];
|
|
138
|
+
for (const item of missing) {
|
|
139
|
+
const result = await services.app.restoreArtifact(item);
|
|
140
|
+
results.push(result);
|
|
141
|
+
const { app } = item;
|
|
142
|
+
const wasActive = app.status === 'running' || app.status === 'deploying';
|
|
143
|
+
try {
|
|
144
|
+
const synced = await services.app.syncStatus({ name: app.name, recoverIfMissing: result.action === 'restored' && wasActive });
|
|
145
|
+
if (result.action !== 'restored' && wasActive && synced?.status !== 'running') {
|
|
146
|
+
await app.update({ status: 'error', message: `current version artifact missing: ${result.reason}` });
|
|
147
|
+
}
|
|
148
|
+
} catch (e) {
|
|
149
|
+
fastify.log.warn({ err: e, app: app.name }, 'sync status after artifact restore failed');
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
return results;
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const recoverOnStartup = async () => {
|
|
156
|
+
const { services } = fastify[options.name];
|
|
157
|
+
const missing = await services.app.listMissingArtifacts();
|
|
158
|
+
// 两路并行:部署目录完好的应用不必等其它应用重装依赖
|
|
159
|
+
const [reconciled, restored] = await Promise.all([
|
|
160
|
+
reconcile({ excludeIds: missing.map(({ app }) => app.id) }).catch(err => {
|
|
161
|
+
fastify.log.warn({ err }, 'async reconcile/sync statuses failed');
|
|
162
|
+
return [];
|
|
163
|
+
}),
|
|
164
|
+
restoreArtifacts(missing).catch(err => {
|
|
165
|
+
fastify.log.warn({ err }, 'restore missing artifacts failed');
|
|
166
|
+
return [];
|
|
167
|
+
})
|
|
168
|
+
]);
|
|
169
|
+
// 只保留当前版本目录,其余版本部署时再从宿主文件解压
|
|
170
|
+
const pruned = await services.app.pruneAllVersionArtifacts().catch(err => {
|
|
171
|
+
fastify.log.warn({ err }, 'prune version artifacts failed');
|
|
172
|
+
return [];
|
|
173
|
+
});
|
|
174
|
+
return { reconciled, restored, pruned };
|
|
175
|
+
};
|
|
176
|
+
|
|
114
177
|
const runRotation = async ({ now = new Date() } = {}) => {
|
|
115
178
|
const { models } = fastify[options.name];
|
|
116
179
|
const apps = await models.app.findAll({ attributes: ['name', 'rootPath'] });
|
|
@@ -244,6 +307,6 @@ module.exports = fp(async (fastify, options) => {
|
|
|
244
307
|
};
|
|
245
308
|
|
|
246
309
|
Object.assign(fastify[options.name].services, {
|
|
247
|
-
bootstrap: { onReady, onClose, reconcile, rotateLogs, sampleLoad }
|
|
310
|
+
bootstrap: { onReady, onClose, ensureSchema, recoverOnStartup, reconcile, rotateLogs, sampleLoad }
|
|
248
311
|
});
|
|
249
312
|
});
|