@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.
@@ -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 { listSqlFiles } = require('../utils/validatePackage');
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 uploadVersion = async ({ name, version, label, zipBuffer }) => {
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
- return await models.appVersion.sequelize.transaction(async transaction => {
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: prepared.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
- await fs.move(stagingPath, artifactPath);
346
+ if (keepArtifact) {
347
+ await fs.move(stagingPath, artifactPath);
348
+ }
275
349
  await versionRow.update({ artifactPath }, { transaction });
276
- return versionRow.toJSON();
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 json = row.toJSON();
301
- if (json.hasMigration) {
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).catch(err => {
436
- fastify.log.warn({ err, appId }, 'syncRealStatus failed');
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 deploy = async ({ name, versionId, version, runMigration = true, migrations }) => {
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
- const currentLink = path.join(app.rootPath, 'current');
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
- scheduleStatusSync(app.id, { beforeTables: beforeForReady });
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 真实状态异步对齐 DB
110
+ // 父应用重启后不阻塞 ready:后台按 PM2 真实状态对齐 DB,缺部署目录的应用另行补回后再拉起
91
111
  setImmediate(() => {
92
- reconcile().catch(err => {
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
  });